newTraceDetailPanel.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. import {createRef, Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import omit from 'lodash/omit';
  5. import Alert from 'sentry/components/alert';
  6. import {Button} from 'sentry/components/button';
  7. import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
  8. import DateTime from 'sentry/components/dateTime';
  9. import {Chunk} from 'sentry/components/events/contexts/chunk';
  10. import {EventAttachments} from 'sentry/components/events/eventAttachments';
  11. import {
  12. isNotMarkMeasurement,
  13. isNotPerformanceScoreMeasurement,
  14. TraceEventCustomPerformanceMetric,
  15. } from 'sentry/components/events/eventCustomPerformanceMetrics';
  16. import {Entries} from 'sentry/components/events/eventEntries';
  17. import {EventEvidence} from 'sentry/components/events/eventEvidence';
  18. import {EventExtraData} from 'sentry/components/events/eventExtraData';
  19. import {EventSdk} from 'sentry/components/events/eventSdk';
  20. import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
  21. import {Breadcrumbs} from 'sentry/components/events/interfaces/breadcrumbs';
  22. import NewTraceDetailsSpanDetail, {
  23. SpanDetailContainer,
  24. SpanDetails,
  25. } from 'sentry/components/events/interfaces/spans/newTraceDetailsSpanDetails';
  26. import {
  27. getFormattedTimeRangeWithLeadingAndTrailingZero,
  28. getSpanOperation,
  29. parseTrace,
  30. } from 'sentry/components/events/interfaces/spans/utils';
  31. import {generateStats} from 'sentry/components/events/opsBreakdown';
  32. import {EventRRWebIntegration} from 'sentry/components/events/rrwebIntegration';
  33. import {DataSection} from 'sentry/components/events/styles';
  34. import FileSize from 'sentry/components/fileSize';
  35. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  36. import Link from 'sentry/components/links/link';
  37. import LoadingIndicator from 'sentry/components/loadingIndicator';
  38. import {
  39. ErrorDot,
  40. ErrorLevel,
  41. ErrorMessageContent,
  42. ErrorMessageTitle,
  43. ErrorTitle,
  44. } from 'sentry/components/performance/waterfall/rowDetails';
  45. import PerformanceDuration from 'sentry/components/performanceDuration';
  46. import QuestionTooltip from 'sentry/components/questionTooltip';
  47. import {generateIssueEventTarget} from 'sentry/components/quickTrace/utils';
  48. import {Tooltip} from 'sentry/components/tooltip';
  49. import {PAGE_URL_PARAM} from 'sentry/constants/pageFilters';
  50. import {IconChevron, IconOpen} from 'sentry/icons';
  51. import {t, tn} from 'sentry/locale';
  52. import {space} from 'sentry/styles/space';
  53. import type {EntryBreadcrumbs, EventTransaction, Organization} from 'sentry/types';
  54. import {EntryType} from 'sentry/types';
  55. import {objectIsEmpty} from 'sentry/utils';
  56. import {trackAnalytics} from 'sentry/utils/analytics';
  57. import getDynamicText from 'sentry/utils/getDynamicText';
  58. import {PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
  59. import {WEB_VITAL_DETAILS} from 'sentry/utils/performance/vitals/constants';
  60. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  61. import {useApiQuery} from 'sentry/utils/queryClient';
  62. import {useLocation} from 'sentry/utils/useLocation';
  63. import useOrganization from 'sentry/utils/useOrganization';
  64. import useProjects from 'sentry/utils/useProjects';
  65. import {isCustomMeasurement} from 'sentry/views/dashboards/utils';
  66. import {CustomMetricsEventData} from 'sentry/views/ddm/customMetricsEventData';
  67. import {
  68. isSpanNode,
  69. isTransactionNode,
  70. } from 'sentry/views/performance/newTraceDetails/guards';
  71. import {ProfileGroupProvider} from 'sentry/views/profiling/profileGroupProvider';
  72. import {ProfileContext, ProfilesProvider} from 'sentry/views/profiling/profilesProvider';
  73. import DetailPanel from 'sentry/views/starfish/components/detailPanel';
  74. import {Row, Tags} from '../traceDetails/styles';
  75. import {transactionSummaryRouteWithQuery} from '../transactionSummary/utils';
  76. import type {TraceTree, TraceTreeNode} from './traceTree';
  77. type EventDetailProps = {
  78. location: Location;
  79. node: TraceTreeNode<TraceTree.Transaction>;
  80. organization: Organization;
  81. };
  82. function OpsBreakdown({event}: {event: EventTransaction}) {
  83. const [showingAll, setShowingAll] = useState(false);
  84. const breakdown = event && generateStats(event, {type: 'no_filter'});
  85. if (!breakdown) {
  86. return null;
  87. }
  88. const renderText = showingAll ? t('Show less') : t('Show more') + '...';
  89. return (
  90. breakdown && (
  91. <Row
  92. title={
  93. <FlexBox style={{gap: '5px'}}>
  94. {t('Ops Breakdown')}
  95. <QuestionTooltip
  96. title={t('Applicable to the children of this event only')}
  97. size="xs"
  98. />
  99. </FlexBox>
  100. }
  101. >
  102. <div style={{display: 'flex', flexDirection: 'column', gap: space(0.25)}}>
  103. {breakdown.slice(0, showingAll ? breakdown.length : 5).map(currOp => {
  104. const {name, percentage, totalInterval} = currOp;
  105. const operationName = typeof name === 'string' ? name : t('Other');
  106. const pctLabel = isFinite(percentage) ? Math.round(percentage * 100) : '∞';
  107. return (
  108. <div key={operationName}>
  109. {operationName}:{' '}
  110. <PerformanceDuration seconds={totalInterval} abbreviation /> ({pctLabel}%)
  111. </div>
  112. );
  113. })}
  114. {breakdown.length > 5 && (
  115. <a onClick={() => setShowingAll(prev => !prev)}>{renderText}</a>
  116. )}
  117. </div>
  118. </Row>
  119. )
  120. );
  121. }
  122. function BreadCrumbsSection({
  123. event,
  124. organization,
  125. }: {
  126. event: EventTransaction;
  127. organization: Organization;
  128. }) {
  129. const [showBreadCrumbs, setShowBreadCrumbs] = useState(false);
  130. const breadCrumbsContainerRef = createRef<HTMLDivElement>();
  131. useEffect(() => {
  132. setTimeout(() => {
  133. if (showBreadCrumbs) {
  134. breadCrumbsContainerRef.current?.scrollIntoView({
  135. behavior: 'smooth',
  136. block: 'end',
  137. });
  138. }
  139. }, 100);
  140. }, [showBreadCrumbs, breadCrumbsContainerRef]);
  141. const matchingEntry: EntryBreadcrumbs | undefined = event?.entries.find(
  142. (entry): entry is EntryBreadcrumbs => entry.type === EntryType.BREADCRUMBS
  143. );
  144. if (!matchingEntry) {
  145. return null;
  146. }
  147. const renderText = showBreadCrumbs ? t('Hide Breadcrumbs') : t('Show Breadcrumbs');
  148. const chevron = <IconChevron size="xs" direction={showBreadCrumbs ? 'up' : 'down'} />;
  149. return (
  150. <Fragment>
  151. <a
  152. style={{display: 'flex', alignItems: 'center', gap: space(0.5)}}
  153. onClick={() => {
  154. setShowBreadCrumbs(prev => !prev);
  155. }}
  156. >
  157. {renderText} {chevron}
  158. </a>
  159. <div ref={breadCrumbsContainerRef}>
  160. {showBreadCrumbs && (
  161. <Breadcrumbs
  162. hideTitle
  163. data={matchingEntry.data}
  164. event={event}
  165. organization={organization}
  166. />
  167. )}
  168. </div>
  169. </Fragment>
  170. );
  171. }
  172. function EventDetails({node, organization, location}: EventDetailProps) {
  173. const {projects} = useProjects();
  174. const {data: event} = useApiQuery<EventTransaction>(
  175. [
  176. `/organizations/${organization.slug}/events/${node.value.project_slug}:${node.value.event_id}/`,
  177. {
  178. query: {
  179. referrer: 'trace-details-summary',
  180. },
  181. },
  182. ],
  183. {
  184. staleTime: 0,
  185. enabled: !!node,
  186. }
  187. );
  188. if (!event) {
  189. return <LoadingIndicator />;
  190. }
  191. const {user, contexts, projectSlug} = event;
  192. const {feedback} = contexts ?? {};
  193. const eventJsonUrl = `/api/0/projects/${organization.slug}/${node.value.project_slug}/events/${node.value.event_id}/json/`;
  194. const project = projects.find(proj => proj.slug === event?.projectSlug);
  195. const {errors, performance_issues} = node.value;
  196. const hasIssues = errors.length + performance_issues.length > 0;
  197. const startTimestamp = Math.min(node.value.start_timestamp, node.value.timestamp);
  198. const endTimestamp = Math.max(node.value.start_timestamp, node.value.timestamp);
  199. const {start: startTimeWithLeadingZero, end: endTimeWithLeadingZero} =
  200. getFormattedTimeRangeWithLeadingAndTrailingZero(startTimestamp, endTimestamp);
  201. const duration = (endTimestamp - startTimestamp) * 1000;
  202. const durationString = `${Number(duration.toFixed(3)).toLocaleString()}ms`;
  203. const measurementNames = Object.keys(node.value.measurements ?? {})
  204. .filter(name => isCustomMeasurement(`measurements.${name}`))
  205. .filter(isNotMarkMeasurement)
  206. .filter(isNotPerformanceScoreMeasurement)
  207. .sort();
  208. const renderMeasurements = () => {
  209. if (!event) {
  210. return null;
  211. }
  212. const {measurements} = event;
  213. const measurementKeys = Object.keys(measurements ?? {})
  214. .filter(name => Boolean(WEB_VITAL_DETAILS[`measurements.${name}`]))
  215. .sort();
  216. if (!measurements || measurementKeys.length <= 0) {
  217. return null;
  218. }
  219. return (
  220. <Fragment>
  221. {measurementKeys.map(measurement => (
  222. <Row
  223. key={measurement}
  224. title={WEB_VITAL_DETAILS[`measurements.${measurement}`]?.name}
  225. >
  226. <PerformanceDuration
  227. milliseconds={Number(measurements[measurement].value.toFixed(3))}
  228. abbreviation
  229. />
  230. </Row>
  231. ))}
  232. </Fragment>
  233. );
  234. };
  235. const renderGoToProfileButton = () => {
  236. if (!node.value.profile_id) {
  237. return null;
  238. }
  239. const target = generateProfileFlamechartRoute({
  240. orgSlug: organization.slug,
  241. projectSlug: node.value.project_slug,
  242. profileId: node.value.profile_id,
  243. });
  244. function handleOnClick() {
  245. trackAnalytics('profiling_views.go_to_flamegraph', {
  246. organization,
  247. source: 'performance.trace_view',
  248. });
  249. }
  250. return (
  251. <StyledButton size="xs" to={target} onClick={handleOnClick}>
  252. {t('View Profile')}
  253. </StyledButton>
  254. );
  255. };
  256. return (
  257. <Wrapper>
  258. <Actions>
  259. <Button
  260. size="sm"
  261. icon={<IconOpen />}
  262. href={eventJsonUrl}
  263. external
  264. onClick={() =>
  265. trackAnalytics('performance_views.event_details.json_button_click', {
  266. organization,
  267. })
  268. }
  269. >
  270. {t('JSON')} (<FileSize bytes={event?.size} />)
  271. </Button>
  272. </Actions>
  273. <Title>
  274. <Tooltip title={node.value.project_slug}>
  275. <ProjectBadge
  276. project={project ? project : {slug: node.value.project_slug}}
  277. avatarSize={50}
  278. hideName
  279. />
  280. </Tooltip>
  281. <div>
  282. <div>{t('Event')}</div>
  283. <TransactionOp> {node.value['transaction.op']}</TransactionOp>
  284. </div>
  285. </Title>
  286. {hasIssues && (
  287. <Alert
  288. system
  289. defaultExpanded
  290. type="error"
  291. expand={[...node.value.errors, ...node.value.performance_issues].map(error => (
  292. <ErrorMessageContent key={error.event_id}>
  293. <ErrorDot level={error.level} />
  294. <ErrorLevel>{error.level}</ErrorLevel>
  295. <ErrorTitle>
  296. <Link to={generateIssueEventTarget(error, organization)}>
  297. {error.title}
  298. </Link>
  299. </ErrorTitle>
  300. </ErrorMessageContent>
  301. ))}
  302. >
  303. <ErrorMessageTitle>
  304. {tn(
  305. '%s issue occurred in this transaction.',
  306. '%s issues occurred in this transaction.',
  307. node.value.errors.length + node.value.performance_issues.length
  308. )}
  309. </ErrorMessageTitle>
  310. </Alert>
  311. )}
  312. <StyledTable className="table key-value">
  313. <tbody>
  314. <Row title={<TransactionIdTitle>{t('Event ID')}</TransactionIdTitle>}>
  315. {node.value.event_id}
  316. <CopyToClipboardButton
  317. borderless
  318. size="zero"
  319. iconSize="xs"
  320. text={`${window.location.href.replace(window.location.hash, '')}#txn-${
  321. node.value.event_id
  322. }`}
  323. />
  324. </Row>
  325. <Row title={t('Description')}>
  326. <Link
  327. to={transactionSummaryRouteWithQuery({
  328. orgSlug: organization.slug,
  329. transaction: node.value.transaction,
  330. query: omit(location.query, Object.values(PAGE_URL_PARAM)),
  331. projectID: String(node.value.project_id),
  332. })}
  333. >
  334. {node.value.transaction}
  335. </Link>
  336. </Row>
  337. {node.value.profile_id && (
  338. <Row title="Profile ID" extra={renderGoToProfileButton()}>
  339. {node.value.profile_id}
  340. </Row>
  341. )}
  342. <Row title="Duration">{durationString}</Row>
  343. <Row title="Date Range">
  344. {getDynamicText({
  345. fixed: 'Mar 19, 2021 11:06:27 AM UTC',
  346. value: (
  347. <Fragment>
  348. <DateTime date={startTimestamp * 1000} />
  349. {` (${startTimeWithLeadingZero})`}
  350. </Fragment>
  351. ),
  352. })}
  353. <br />
  354. {getDynamicText({
  355. fixed: 'Mar 19, 2021 11:06:28 AM UTC',
  356. value: (
  357. <Fragment>
  358. <DateTime date={endTimestamp * 1000} />
  359. {` (${endTimeWithLeadingZero})`}
  360. </Fragment>
  361. ),
  362. })}
  363. </Row>
  364. <OpsBreakdown event={event} />
  365. {renderMeasurements()}
  366. <Tags
  367. enableHiding
  368. location={location}
  369. organization={organization}
  370. transaction={node.value}
  371. />
  372. {measurementNames.length > 0 && (
  373. <tr>
  374. <td className="key">{t('Measurements')}</td>
  375. <td className="value">
  376. <Measurements>
  377. {measurementNames.map(name => {
  378. return (
  379. event && (
  380. <TraceEventCustomPerformanceMetric
  381. key={name}
  382. event={event}
  383. name={name}
  384. location={location}
  385. organization={organization}
  386. source={undefined}
  387. isHomepage={false}
  388. />
  389. )
  390. );
  391. })}
  392. </Measurements>
  393. </td>
  394. </tr>
  395. )}
  396. </tbody>
  397. </StyledTable>
  398. {project && <EventEvidence event={event} project={project} />}
  399. {projectSlug && (
  400. <Entries
  401. definedEvent={event}
  402. projectSlug={projectSlug}
  403. group={undefined}
  404. organization={organization}
  405. isShare={false}
  406. hideBeforeReplayEntries
  407. hideBreadCrumbs
  408. />
  409. )}
  410. {!objectIsEmpty(feedback) && (
  411. <Chunk
  412. key="feedback"
  413. type="feedback"
  414. alias="feedback"
  415. group={undefined}
  416. event={event}
  417. value={feedback}
  418. />
  419. )}
  420. {user && !objectIsEmpty(user) && (
  421. <Chunk
  422. key="user"
  423. type="user"
  424. alias="user"
  425. group={undefined}
  426. event={event}
  427. value={user}
  428. />
  429. )}
  430. <EventExtraData event={event} />
  431. <EventSdk sdk={event.sdk} meta={event._meta?.sdk} />
  432. {event._metrics_summary ? (
  433. <CustomMetricsEventData
  434. metricsSummary={event._metrics_summary}
  435. startTimestamp={event.startTimestamp}
  436. />
  437. ) : null}
  438. <BreadCrumbsSection event={event} organization={organization} />
  439. {projectSlug && <EventAttachments event={event} projectSlug={projectSlug} />}
  440. {project && <EventViewHierarchy event={event} project={project} />}
  441. {projectSlug && (
  442. <EventRRWebIntegration
  443. event={event}
  444. orgId={organization.slug}
  445. projectSlug={projectSlug}
  446. />
  447. )}
  448. </Wrapper>
  449. );
  450. }
  451. function SpanDetailsBody({
  452. node,
  453. organization,
  454. }: {
  455. node: TraceTreeNode<TraceTree.Span>;
  456. organization: Organization;
  457. }) {
  458. const {projects} = useProjects();
  459. const {event, relatedErrors, childTxn, ...span} = node.value;
  460. const project = projects.find(proj => proj.slug === event?.projectSlug);
  461. const profileId = event?.contexts?.profile?.profile_id ?? null;
  462. return (
  463. <Wrapper>
  464. <Title>
  465. <Tooltip title={event.projectSlug}>
  466. <ProjectBadge
  467. project={project ? project : {slug: event.projectSlug || ''}}
  468. avatarSize={50}
  469. hideName
  470. />
  471. </Tooltip>
  472. <div>
  473. <div>{t('Span')}</div>
  474. <TransactionOp> {getSpanOperation(span)}</TransactionOp>
  475. </div>
  476. </Title>
  477. {event.projectSlug && (
  478. <ProfilesProvider
  479. orgSlug={organization.slug}
  480. projectSlug={event.projectSlug}
  481. profileId={profileId || ''}
  482. >
  483. <ProfileContext.Consumer>
  484. {profiles => (
  485. <ProfileGroupProvider
  486. type="flamechart"
  487. input={profiles?.type === 'resolved' ? profiles.data : null}
  488. traceID={profileId || ''}
  489. >
  490. <NewTraceDetailsSpanDetail
  491. relatedErrors={relatedErrors}
  492. childTransactions={childTxn ? [childTxn] : []}
  493. event={event}
  494. openPanel="open"
  495. organization={organization}
  496. span={span}
  497. trace={parseTrace(event)}
  498. />
  499. </ProfileGroupProvider>
  500. )}
  501. </ProfileContext.Consumer>
  502. </ProfilesProvider>
  503. )}
  504. </Wrapper>
  505. );
  506. }
  507. interface TraceDetailPanelProps {
  508. node: TraceTreeNode<TraceTree.NodeValue> | null;
  509. onClose: () => void;
  510. }
  511. function TraceDetailPanel(props: TraceDetailPanelProps) {
  512. const location = useLocation();
  513. const organization = useOrganization();
  514. if (props.node && !(isTransactionNode(props.node) || isSpanNode(props.node))) {
  515. return null;
  516. }
  517. return (
  518. <PageAlertProvider>
  519. <DetailPanel
  520. detailKey={props.node ? 'open' : undefined}
  521. onClose={props.onClose}
  522. skipCloseOnOutsideClick
  523. >
  524. {props.node &&
  525. (isTransactionNode(props.node) ? (
  526. <EventDetails
  527. location={location}
  528. organization={organization}
  529. node={props.node}
  530. />
  531. ) : (
  532. <SpanDetailsBody organization={organization} node={props.node} />
  533. ))}
  534. </DetailPanel>
  535. </PageAlertProvider>
  536. );
  537. }
  538. const Wrapper = styled('div')`
  539. display: flex;
  540. flex-direction: column;
  541. gap: ${space(2)};
  542. ${DataSection} {
  543. padding: 0;
  544. }
  545. ${SpanDetails} {
  546. padding: 0;
  547. }
  548. ${SpanDetailContainer} {
  549. border-bottom: none;
  550. }
  551. `;
  552. const FlexBox = styled('div')`
  553. display: flex;
  554. align-items: center;
  555. `;
  556. const Actions = styled('div')`
  557. display: flex;
  558. align-items: center;
  559. justify-content: flex-end;
  560. `;
  561. const Title = styled(FlexBox)`
  562. gap: ${space(2)};
  563. `;
  564. const TransactionOp = styled('div')`
  565. font-size: 25px;
  566. font-weight: bold;
  567. max-width: 600px;
  568. ${p => p.theme.overflowEllipsis}
  569. `;
  570. const TransactionIdTitle = styled('a')`
  571. display: flex;
  572. color: ${p => p.theme.textColor};
  573. :hover {
  574. color: ${p => p.theme.textColor};
  575. }
  576. `;
  577. const Measurements = styled('div')`
  578. display: flex;
  579. flex-wrap: wrap;
  580. gap: ${space(1)};
  581. padding-top: 10px;
  582. `;
  583. const StyledButton = styled(Button)`
  584. position: absolute;
  585. top: ${space(0.75)};
  586. right: ${space(0.5)};
  587. `;
  588. const StyledTable = styled('table')`
  589. margin-bottom: 0 !important;
  590. `;
  591. export default TraceDetailPanel;