index.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  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. />
  315. );
  316. }
  317. /**
  318. * When there is more than 1 event, clicking the node should expand a dropdown to
  319. * allow the user to select which event to go to.
  320. */
  321. const hoverText = tct('View [eventPrefix] [eventType]', {
  322. eventPrefix: TOOLTIP_PREFIX[nodeKey],
  323. eventType:
  324. errors.length && events.length
  325. ? 'events'
  326. : events.length
  327. ? 'transactions'
  328. : 'errors',
  329. });
  330. return (
  331. <DropdownContainer>
  332. <DropdownLink
  333. caret={false}
  334. title={<StyledEventNode text={text} hoverText={hoverText} type={type} />}
  335. anchorRight={anchor === 'right'}
  336. >
  337. {errors.length > 0 && (
  338. <DropdownMenuHeader first>
  339. {tn('Related Error', 'Related Errors', errors.length)}
  340. </DropdownMenuHeader>
  341. )}
  342. {errors.slice(0, numEvents).map(error => {
  343. const target = generateSingleErrorTarget(
  344. error,
  345. organization,
  346. location,
  347. errorDest
  348. );
  349. return (
  350. <DropdownNodeItem
  351. key={error.event_id}
  352. event={error}
  353. to={target}
  354. allowDefaultEvent
  355. onSelect={() => handleDropdownItem(nodeKey, organization, false)}
  356. organization={organization}
  357. anchor={anchor}
  358. />
  359. );
  360. })}
  361. {events.length > 0 && (
  362. <DropdownMenuHeader first={errors.length === 0}>
  363. {tn('Transaction', 'Transactions', events.length)}
  364. </DropdownMenuHeader>
  365. )}
  366. {events.slice(0, numEvents).map(event => {
  367. const target = generateSingleTransactionTarget(
  368. event,
  369. organization,
  370. location,
  371. transactionDest
  372. );
  373. return (
  374. <DropdownNodeItem
  375. key={event.event_id}
  376. event={event}
  377. to={target}
  378. onSelect={() => handleDropdownItem(nodeKey, organization, false)}
  379. allowDefaultEvent
  380. organization={organization}
  381. subtext={getDuration(
  382. event['transaction.duration'] / 1000,
  383. event['transaction.duration'] < 1000 ? 0 : 2,
  384. true
  385. )}
  386. anchor={anchor}
  387. />
  388. );
  389. })}
  390. {(errors.length > numEvents || events.length > numEvents) && (
  391. <DropdownItem
  392. to={generateTraceTarget(currentEvent, organization)}
  393. allowDefaultEvent
  394. onSelect={() => handleDropdownItem(nodeKey, organization, true)}
  395. >
  396. {t('View all events')}
  397. </DropdownItem>
  398. )}
  399. </DropdownLink>
  400. </DropdownContainer>
  401. );
  402. }
  403. type DropdownNodeProps = {
  404. anchor: 'left' | 'right';
  405. event: TraceError | QuickTraceEvent;
  406. organization: OrganizationSummary;
  407. allowDefaultEvent?: boolean;
  408. onSelect?: (eventKey: any) => void;
  409. subtext?: string;
  410. to?: LocationDescriptor;
  411. };
  412. function DropdownNodeItem({
  413. event,
  414. onSelect,
  415. to,
  416. allowDefaultEvent,
  417. organization,
  418. subtext,
  419. anchor,
  420. }: DropdownNodeProps) {
  421. return (
  422. <DropdownItem to={to} onSelect={onSelect} allowDefaultEvent={allowDefaultEvent}>
  423. <DropdownItemSubContainer>
  424. <Projects orgId={organization.slug} slugs={[event.project_slug]}>
  425. {({projects}) => {
  426. const project = projects.find(p => p.slug === event.project_slug);
  427. return (
  428. <ProjectBadge
  429. disableLink
  430. hideName
  431. project={project ? project : {slug: event.project_slug}}
  432. avatarSize={16}
  433. />
  434. );
  435. }}
  436. </Projects>
  437. {isQuickTraceEvent(event) ? (
  438. <QuickTraceValue
  439. value={event.transaction}
  440. // expand in the opposite direction of the anchor
  441. expandDirection={anchor === 'left' ? 'right' : 'left'}
  442. maxLength={35}
  443. leftTrim
  444. trimRegex={/\.|\//g}
  445. />
  446. ) : (
  447. <QuickTraceValue
  448. value={event.title}
  449. // expand in the opposite direction of the anchor
  450. expandDirection={anchor === 'left' ? 'right' : 'left'}
  451. maxLength={45}
  452. />
  453. )}
  454. </DropdownItemSubContainer>
  455. {subtext && <SectionSubtext>{subtext}</SectionSubtext>}
  456. </DropdownItem>
  457. );
  458. }
  459. type EventNodeProps = {
  460. hoverText: React.ReactNode;
  461. text: React.ReactNode;
  462. onClick?: (eventKey: any) => void;
  463. to?: LocationDescriptor;
  464. type?: keyof Theme['tag'];
  465. };
  466. function StyledEventNode({text, hoverText, to, onClick, type = 'white'}: EventNodeProps) {
  467. return (
  468. <Tooltip position="top" containerDisplayMode="inline-flex" title={hoverText}>
  469. <EventNode type={type} icon={null} to={to} onClick={onClick}>
  470. {text}
  471. </EventNode>
  472. </Tooltip>
  473. );
  474. }
  475. type MissingServiceProps = Pick<QuickTraceProps, 'anchor' | 'organization'> & {
  476. connectorSide: 'left' | 'right';
  477. platform: string;
  478. };
  479. type MissingServiceState = {
  480. hideMissing: boolean;
  481. };
  482. const HIDE_MISSING_SERVICE_KEY = 'quick-trace:hide-missing-services';
  483. // 30 days
  484. const HIDE_MISSING_EXPIRES = 1000 * 60 * 60 * 24 * 30;
  485. function readHideMissingServiceState() {
  486. const value = localStorage.getItem(HIDE_MISSING_SERVICE_KEY);
  487. if (value === null) {
  488. return false;
  489. }
  490. const expires = parseInt(value, 10);
  491. const now = new Date().getTime();
  492. return expires > now;
  493. }
  494. class MissingServiceNode extends Component<MissingServiceProps, MissingServiceState> {
  495. state: MissingServiceState = {
  496. hideMissing: readHideMissingServiceState(),
  497. };
  498. dismissMissingService = () => {
  499. const {organization, platform} = this.props;
  500. const now = new Date().getTime();
  501. localStorage.setItem(
  502. HIDE_MISSING_SERVICE_KEY,
  503. (now + HIDE_MISSING_EXPIRES).toString()
  504. );
  505. this.setState({hideMissing: true});
  506. trackAnalyticsEvent({
  507. eventKey: 'quick_trace.missing_service.dismiss',
  508. eventName: 'Quick Trace: Missing Service Dismissed',
  509. organization_id: parseInt(organization.id, 10),
  510. platform,
  511. });
  512. };
  513. trackExternalLink = () => {
  514. const {organization, platform} = this.props;
  515. trackAnalyticsEvent({
  516. eventKey: 'quick_trace.missing_service.docs',
  517. eventName: 'Quick Trace: Missing Service Clicked',
  518. organization_id: parseInt(organization.id, 10),
  519. platform,
  520. });
  521. };
  522. render() {
  523. const {hideMissing} = this.state;
  524. const {anchor, connectorSide, platform} = this.props;
  525. if (hideMissing) {
  526. return null;
  527. }
  528. const docPlatform = getDocsPlatform(platform, true);
  529. const docsHref =
  530. docPlatform === null || docPlatform === 'javascript'
  531. ? 'https://docs.sentry.io/platforms/javascript/performance/connect-services/'
  532. : `https://docs.sentry.io/platforms/${docPlatform}/performance/connect-services`;
  533. return (
  534. <Fragment>
  535. {connectorSide === 'left' && <TraceConnector />}
  536. <DropdownContainer>
  537. <DropdownLink
  538. caret={false}
  539. title={
  540. <StyledEventNode
  541. type="white"
  542. hoverText={t('No services connected')}
  543. text="???"
  544. />
  545. }
  546. anchorRight={anchor === 'right'}
  547. >
  548. <DropdownItem width="small">
  549. <ExternalDropdownLink href={docsHref} onClick={this.trackExternalLink}>
  550. {t('Connect to a service')}
  551. </ExternalDropdownLink>
  552. </DropdownItem>
  553. <DropdownItem onSelect={this.dismissMissingService} width="small">
  554. {t('Dismiss')}
  555. </DropdownItem>
  556. </DropdownLink>
  557. </DropdownContainer>
  558. {connectorSide === 'right' && <TraceConnector />}
  559. </Fragment>
  560. );
  561. }
  562. }