styles.tsx 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193
  1. import {Fragment, type PropsWithChildren, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {LocationDescriptor} from 'history';
  4. import {Button, LinkButton} from 'sentry/components/button';
  5. import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
  6. import {
  7. DropdownMenu,
  8. type DropdownMenuProps,
  9. type MenuItemProps,
  10. } from 'sentry/components/dropdownMenu';
  11. import EventTagsDataSection from 'sentry/components/events/eventTagsAndScreenshot/tags';
  12. import {generateStats} from 'sentry/components/events/opsBreakdown';
  13. import {DataSection} from 'sentry/components/events/styles';
  14. import FileSize from 'sentry/components/fileSize';
  15. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  16. import KeyValueData, {
  17. CardPanel,
  18. type KeyValueDataContentProps,
  19. Subject,
  20. } from 'sentry/components/keyValueData';
  21. import {LazyRender, type LazyRenderProps} from 'sentry/components/lazyRender';
  22. import Link from 'sentry/components/links/link';
  23. import Panel from 'sentry/components/panels/panel';
  24. import PanelBody from 'sentry/components/panels/panelBody';
  25. import PanelHeader from 'sentry/components/panels/panelHeader';
  26. import {pickBarColor} from 'sentry/components/performance/waterfall/utils';
  27. import QuestionTooltip from 'sentry/components/questionTooltip';
  28. import {Tooltip} from 'sentry/components/tooltip';
  29. import {
  30. IconChevron,
  31. IconCircleFill,
  32. IconFocus,
  33. IconJson,
  34. IconOpen,
  35. IconPanel,
  36. IconProfiling,
  37. } from 'sentry/icons';
  38. import {t, tct} from 'sentry/locale';
  39. import {space} from 'sentry/styles/space';
  40. import type {Event, EventTransaction} from 'sentry/types/event';
  41. import type {KeyValueListData} from 'sentry/types/group';
  42. import type {Organization} from 'sentry/types/organization';
  43. import type {Project} from 'sentry/types/project';
  44. import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10';
  45. import getDuration from 'sentry/utils/duration/getDuration';
  46. import type {Color, ColorOrAlias} from 'sentry/utils/theme';
  47. import {useNavigate} from 'sentry/utils/useNavigate';
  48. import useOrganization from 'sentry/utils/useOrganization';
  49. import {useParams} from 'sentry/utils/useParams';
  50. import {traceAnalytics} from '../../traceAnalytics';
  51. import {useTransaction} from '../../traceApi/useTransaction';
  52. import {useDrawerContainerRef} from '../../traceDrawer/details/drawerContainerRefContext';
  53. import {makeTraceContinuousProfilingLink} from '../../traceDrawer/traceProfilingLink';
  54. import {
  55. isAutogroupedNode,
  56. isMissingInstrumentationNode,
  57. isRootNode,
  58. isSpanNode,
  59. isTraceErrorNode,
  60. isTransactionNode,
  61. } from '../../traceGuards';
  62. import type {MissingInstrumentationNode} from '../../traceModels/missingInstrumentationNode';
  63. import type {ParentAutogroupNode} from '../../traceModels/parentAutogroupNode';
  64. import type {SiblingAutogroupNode} from '../../traceModels/siblingAutogroupNode';
  65. import {TraceTree} from '../../traceModels/traceTree';
  66. import type {TraceTreeNode} from '../../traceModels/traceTreeNode';
  67. import {useTraceState, useTraceStateDispatch} from '../../traceState/traceStateProvider';
  68. import {useHasTraceNewUi} from '../../useHasTraceNewUi';
  69. const DetailContainer = styled('div')<{hasNewTraceUi?: boolean}>`
  70. display: flex;
  71. flex-direction: column;
  72. gap: ${p => (p.hasNewTraceUi ? 0 : space(2))};
  73. padding: ${space(1)};
  74. ${DataSection} {
  75. padding: 0;
  76. }
  77. `;
  78. const FlexBox = styled('div')`
  79. display: flex;
  80. align-items: center;
  81. `;
  82. const Actions = styled(FlexBox)`
  83. gap: ${space(0.5)};
  84. justify-content: end;
  85. width: 100%;
  86. `;
  87. const Title = styled(FlexBox)`
  88. gap: ${space(1)};
  89. flex-grow: 1;
  90. overflow: hidden;
  91. > span {
  92. min-width: 30px;
  93. }
  94. `;
  95. const LegacyTitleText = styled('div')`
  96. ${p => p.theme.overflowEllipsis}
  97. `;
  98. const TitleText = styled('div')`
  99. font-size: ${p => p.theme.fontSizeExtraLarge};
  100. font-weight: bold;
  101. `;
  102. function TitleWithTestId(props: PropsWithChildren<{}>) {
  103. return <Title data-test-id="trace-drawer-title">{props.children}</Title>;
  104. }
  105. function SubtitleWithCopyButton({text}: {text: string}) {
  106. return (
  107. <SubTitleWrapper>
  108. <StyledSubTitleText>{text}</StyledSubTitleText>
  109. <CopyToClipboardButton
  110. borderless
  111. size="zero"
  112. iconSize="xs"
  113. text={text}
  114. tooltipProps={{disabled: true}}
  115. />
  116. </SubTitleWrapper>
  117. );
  118. }
  119. const SubTitleWrapper = styled(FlexBox)`
  120. ${p => p.theme.overflowEllipsis}
  121. `;
  122. const StyledSubTitleText = styled('span')`
  123. font-size: ${p => p.theme.fontSizeMedium};
  124. color: ${p => p.theme.subText};
  125. `;
  126. function TitleOp({text}: {text: string}) {
  127. return (
  128. <Tooltip
  129. title={
  130. <Fragment>
  131. {text}
  132. <CopyToClipboardButton
  133. borderless
  134. size="zero"
  135. iconSize="xs"
  136. text={text}
  137. tooltipProps={{disabled: true}}
  138. />
  139. </Fragment>
  140. }
  141. showOnlyOnOverflow
  142. isHoverable
  143. >
  144. <TitleOpText>{text}</TitleOpText>
  145. </Tooltip>
  146. );
  147. }
  148. const Type = styled('div')`
  149. font-size: ${p => p.theme.fontSizeSmall};
  150. `;
  151. const TitleOpText = styled('div')`
  152. font-size: 15px;
  153. font-weight: ${p => p.theme.fontWeightBold};
  154. ${p => p.theme.overflowEllipsis}
  155. `;
  156. const Table = styled('table')`
  157. margin-bottom: 0 !important;
  158. td {
  159. overflow: hidden;
  160. }
  161. `;
  162. const IconTitleWrapper = styled(FlexBox)`
  163. gap: ${space(1)};
  164. min-width: 30px;
  165. `;
  166. const IconBorder = styled('div')<{backgroundColor: string; errored?: boolean}>`
  167. background-color: ${p => p.backgroundColor};
  168. border-radius: ${p => p.theme.borderRadius};
  169. padding: 0;
  170. display: flex;
  171. align-items: center;
  172. justify-content: center;
  173. width: 30px;
  174. height: 30px;
  175. min-width: 30px;
  176. svg {
  177. fill: ${p => p.theme.white};
  178. width: 14px;
  179. height: 14px;
  180. }
  181. `;
  182. const LegacyHeaderContainer = styled(FlexBox)`
  183. justify-content: space-between;
  184. gap: ${space(3)};
  185. container-type: inline-size;
  186. @container (max-width: 780px) {
  187. .DropdownMenu {
  188. display: block;
  189. }
  190. .Actions {
  191. display: none;
  192. }
  193. }
  194. @container (min-width: 781px) {
  195. .DropdownMenu {
  196. display: none;
  197. }
  198. }
  199. `;
  200. const HeaderContainer = styled(FlexBox)`
  201. align-items: baseline;
  202. justify-content: space-between;
  203. gap: ${space(3)};
  204. margin-bottom: ${space(2)};
  205. `;
  206. const DURATION_COMPARISON_STATUS_COLORS: {
  207. equal: {light: ColorOrAlias; normal: ColorOrAlias};
  208. faster: {light: ColorOrAlias; normal: ColorOrAlias};
  209. slower: {light: ColorOrAlias; normal: ColorOrAlias};
  210. } = {
  211. faster: {
  212. light: 'green100',
  213. normal: 'green300',
  214. },
  215. slower: {
  216. light: 'red100',
  217. normal: 'red300',
  218. },
  219. equal: {
  220. light: 'gray100',
  221. normal: 'gray300',
  222. },
  223. };
  224. const MIN_PCT_DURATION_DIFFERENCE = 10;
  225. type DurationComparison = {
  226. deltaPct: number;
  227. deltaText: JSX.Element;
  228. status: 'faster' | 'slower' | 'equal';
  229. } | null;
  230. const getDurationComparison = (
  231. baseline: number | undefined,
  232. duration: number,
  233. baseDescription?: string
  234. ): DurationComparison => {
  235. if (!baseline) {
  236. return null;
  237. }
  238. const delta = duration - baseline;
  239. const deltaPct = Math.round(Math.abs((delta / baseline) * 100));
  240. const status = delta > 0 ? 'slower' : delta < 0 ? 'faster' : 'equal';
  241. const formattedBaseDuration = (
  242. <Tooltip
  243. title={baseDescription}
  244. showUnderline
  245. underlineColor={DURATION_COMPARISON_STATUS_COLORS[status].normal}
  246. >
  247. {getDuration(baseline, 2, true)}
  248. </Tooltip>
  249. );
  250. const deltaText =
  251. status === 'equal'
  252. ? tct(`equal to avg [formattedBaseDuration]`, {
  253. formattedBaseDuration,
  254. })
  255. : status === 'faster'
  256. ? tct(`[deltaPct] faster than avg [formattedBaseDuration]`, {
  257. formattedBaseDuration,
  258. deltaPct: `${deltaPct}%`,
  259. })
  260. : tct(`[deltaPct] slower than avg [formattedBaseDuration]`, {
  261. formattedBaseDuration,
  262. deltaPct: `${deltaPct}%`,
  263. });
  264. return {deltaPct, status, deltaText};
  265. };
  266. type DurationProps = {
  267. baseline: number | undefined;
  268. duration: number;
  269. node: TraceTreeNode<TraceTree.NodeValue>;
  270. baseDescription?: string;
  271. ratio?: number;
  272. };
  273. function Duration(props: DurationProps) {
  274. if (typeof props.duration !== 'number' || Number.isNaN(props.duration)) {
  275. return <DurationContainer>{t('unknown')}</DurationContainer>;
  276. }
  277. // Since transactions have ms precision, we show 2 decimal places only if the duration is greater than 1 second.
  278. const precision = isTransactionNode(props.node) ? (props.duration > 1 ? 2 : 0) : 2;
  279. if (props.baseline === undefined || props.baseline === 0) {
  280. return (
  281. <DurationContainer>
  282. {getDuration(props.duration, precision, true)}
  283. </DurationContainer>
  284. );
  285. }
  286. const comparison = getDurationComparison(
  287. props.baseline,
  288. props.duration,
  289. props.baseDescription
  290. );
  291. return (
  292. <Fragment>
  293. <DurationContainer>
  294. {getDuration(props.duration, precision, true)}{' '}
  295. {props.ratio ? `(${(props.ratio * 100).toFixed()}%)` : null}
  296. </DurationContainer>
  297. {comparison && comparison.deltaPct >= MIN_PCT_DURATION_DIFFERENCE ? (
  298. <Comparison status={comparison.status}>{comparison.deltaText}</Comparison>
  299. ) : null}
  300. </Fragment>
  301. );
  302. }
  303. function TableRow({
  304. title,
  305. keep,
  306. children,
  307. prefix,
  308. extra = null,
  309. toolTipText,
  310. }: {
  311. children: React.ReactNode;
  312. title: JSX.Element | string | null;
  313. extra?: React.ReactNode;
  314. keep?: boolean;
  315. prefix?: JSX.Element;
  316. toolTipText?: string;
  317. }) {
  318. if (!keep && !children) {
  319. return null;
  320. }
  321. return (
  322. <tr>
  323. <td className="key">
  324. <Flex>
  325. {prefix}
  326. {title}
  327. {toolTipText ? <StyledQuestionTooltip size="xs" title={toolTipText} /> : null}
  328. </Flex>
  329. </td>
  330. <ValueTd className="value">
  331. <TableValueRow>
  332. <StyledPre>
  333. <span className="val-string">{children}</span>
  334. </StyledPre>
  335. <TableRowButtonContainer>{extra}</TableRowButtonContainer>
  336. </TableValueRow>
  337. </ValueTd>
  338. </tr>
  339. );
  340. }
  341. type HighlightProps = {
  342. avgDuration: number | undefined;
  343. bodyContent: React.ReactNode;
  344. headerContent: React.ReactNode;
  345. node: TraceTreeNode<TraceTree.NodeValue>;
  346. project: Project | undefined;
  347. transaction: EventTransaction | undefined;
  348. };
  349. function Highlights({
  350. node,
  351. transaction: event,
  352. avgDuration,
  353. project,
  354. headerContent,
  355. bodyContent,
  356. }: HighlightProps) {
  357. if (!isTransactionNode(node)) {
  358. return null;
  359. }
  360. const startTimestamp = node.space[0];
  361. const endTimestamp = node.space[0] + node.space[1];
  362. const durationInSeconds = (endTimestamp - startTimestamp) / 1e3;
  363. const comparison = getDurationComparison(
  364. avgDuration,
  365. durationInSeconds,
  366. t('Average duration for this transaction over the last 24 hours')
  367. );
  368. return (
  369. <Fragment>
  370. <HighlightsWrapper>
  371. <HighlightsLeftColumn>
  372. <Tooltip title={node.value.project_slug}>
  373. <ProjectBadge
  374. project={project ? project : {slug: node.value.project_slug}}
  375. avatarSize={18}
  376. hideName
  377. />
  378. </Tooltip>
  379. <VerticalLine />
  380. </HighlightsLeftColumn>
  381. <HighlightsRightColumn>
  382. <HighlightOp>{node.value['transaction.op']}</HighlightOp>
  383. <HighlightsDurationWrapper>
  384. <HighlightDuration>
  385. {getDuration(durationInSeconds, 2, true)}
  386. </HighlightDuration>
  387. {comparison && comparison.deltaPct >= MIN_PCT_DURATION_DIFFERENCE ? (
  388. <HiglightsDurationComparison status={comparison.status}>
  389. {comparison.deltaText}
  390. </HiglightsDurationComparison>
  391. ) : null}
  392. </HighlightsDurationWrapper>
  393. <Panel>
  394. <StyledPanelHeader>{headerContent}</StyledPanelHeader>
  395. <PanelBody>{bodyContent}</PanelBody>
  396. </Panel>
  397. {event ? <HighLightsOpsBreakdown event={event} /> : null}
  398. </HighlightsRightColumn>
  399. </HighlightsWrapper>
  400. <SectionDivider />
  401. </Fragment>
  402. );
  403. }
  404. function HighLightsOpsBreakdown({event}: {event: EventTransaction}) {
  405. const breakdown = generateStats(event, {type: 'no_filter'});
  406. const spansCount =
  407. event.entries?.find(entry => entry.type === 'spans')?.data?.length ?? 0;
  408. return (
  409. <HighlightsOpsBreakdownWrapper>
  410. <HighlightsSpanCount>
  411. {tct('This transaction contains [spansCount] spans', {
  412. spansCount,
  413. })}
  414. </HighlightsSpanCount>
  415. {breakdown.slice(0, 5).map(currOp => {
  416. const {name, percentage} = currOp;
  417. const operationName = typeof name === 'string' ? name : t('Other');
  418. const color = pickBarColor(operationName);
  419. const pctLabel = isFinite(percentage) ? Math.round(percentage * 100) : '∞';
  420. return (
  421. <HighlightsOpRow key={operationName}>
  422. <IconCircleFill size="xs" color={color as Color} />
  423. {operationName}
  424. <HighlightsOpPct>{pctLabel}%</HighlightsOpPct>
  425. </HighlightsOpRow>
  426. );
  427. })}
  428. {breakdown.length > 5 ? (
  429. <HighlightsOpsBreakdownMoreCount>
  430. {tct('+ [moreCount] more', {moreCount: breakdown.length - 5})}
  431. </HighlightsOpsBreakdownMoreCount>
  432. ) : null}
  433. </HighlightsOpsBreakdownWrapper>
  434. );
  435. }
  436. const HighlightsOpsBreakdownMoreCount = styled('div')`
  437. font-size: 12px;
  438. color: ${p => p.theme.subText};
  439. `;
  440. const HighlightsOpPct = styled('div')`
  441. color: ${p => p.theme.subText};
  442. font-size: 14px;
  443. `;
  444. const HighlightsSpanCount = styled('div')`
  445. margin-bottom: ${space(0.25)};
  446. `;
  447. const HighlightsOpRow = styled(FlexBox)`
  448. font-size: 13px;
  449. gap: ${space(0.5)};
  450. `;
  451. const HighlightsOpsBreakdownWrapper = styled(FlexBox)`
  452. align-items: flex-start;
  453. flex-direction: column;
  454. gap: ${space(0.25)};
  455. `;
  456. const HiglightsDurationComparison = styled('div')<{status: string}>`
  457. white-space: nowrap;
  458. border-radius: 12px;
  459. color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].normal]};
  460. background-color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].light]};
  461. border: solid 1px ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].light]};
  462. font-size: ${p => p.theme.fontSizeExtraSmall};
  463. padding: ${space(0.25)} ${space(1)};
  464. display: inline-block;
  465. height: 21px;
  466. `;
  467. const HighlightsDurationWrapper = styled(FlexBox)`
  468. gap: ${space(1)};
  469. margin-bottom: ${space(1)};
  470. `;
  471. const HighlightDuration = styled('div')`
  472. font-size: ${p => p.theme.headerFontSize};
  473. font-weight: 400;
  474. `;
  475. const HighlightOp = styled('div')`
  476. font-weight: bold;
  477. font-size: ${p => p.theme.fontSizeMedium};
  478. line-height: normal;
  479. `;
  480. const StyledPanelHeader = styled(PanelHeader)`
  481. font-weight: normal;
  482. padding: 0;
  483. line-height: normal;
  484. text-transform: none;
  485. font-size: ${p => p.theme.fontSizeMedium};
  486. overflow: hidden;
  487. `;
  488. const SectionDivider = styled('hr')`
  489. border-color: ${p => p.theme.translucentBorder};
  490. margin: ${space(1.5)} 0;
  491. `;
  492. const VerticalLine = styled('div')`
  493. width: 1px;
  494. height: 100%;
  495. background-color: ${p => p.theme.border};
  496. margin-top: ${space(0.5)};
  497. `;
  498. const HighlightsWrapper = styled('div')`
  499. display: flex;
  500. align-items: stretch;
  501. gap: ${space(1)};
  502. width: 100%;
  503. overflow: hidden;
  504. margin: ${space(1)} 0;
  505. `;
  506. const HighlightsLeftColumn = styled('div')`
  507. display: flex;
  508. flex-direction: column;
  509. justify-content: center;
  510. align-items: center;
  511. `;
  512. const HighlightsRightColumn = styled('div')`
  513. display: flex;
  514. flex-direction: column;
  515. justify-content: left;
  516. height: 100%;
  517. flex: 1;
  518. overflow: hidden;
  519. `;
  520. function IssuesLink({
  521. node,
  522. children,
  523. }: {
  524. children: React.ReactNode;
  525. node: TraceTreeNode<TraceTree.NodeValue>;
  526. }) {
  527. const organization = useOrganization();
  528. const params = useParams<{traceSlug?: string}>();
  529. const traceSlug = params.traceSlug?.trim() ?? '';
  530. // Adding a buffer of 15mins for errors only traces, where there is no concept of
  531. // trace duration and start equals end timestamps.
  532. const buffer = node.space[1] > 0 ? 0 : 15 * 60 * 1000;
  533. return (
  534. <Link
  535. to={{
  536. pathname: `/organizations/${organization.slug}/issues/`,
  537. query: {
  538. query: `trace:${traceSlug}`,
  539. start: new Date(node.space[0] - buffer).toISOString(),
  540. end: new Date(node.space[0] + node.space[1] + buffer).toISOString(),
  541. // If we don't pass the project param, the issues page will filter by the last selected project.
  542. // Traces can have multiple projects, so we query issues by all projects and rely on our search query to filter the results.
  543. project: -1,
  544. },
  545. }}
  546. >
  547. {children}
  548. </Link>
  549. );
  550. }
  551. const LAZY_RENDER_PROPS: Partial<LazyRenderProps> = {
  552. observerOptions: {rootMargin: '50px'},
  553. };
  554. const DurationContainer = styled('span')`
  555. font-weight: ${p => p.theme.fontWeightBold};
  556. margin-right: ${space(1)};
  557. `;
  558. const Comparison = styled('span')<{status: 'faster' | 'slower' | 'equal'}>`
  559. color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].normal]};
  560. `;
  561. const Flex = styled('div')`
  562. display: flex;
  563. align-items: center;
  564. `;
  565. const TableValueRow = styled('div')`
  566. display: grid;
  567. grid-template-columns: auto min-content;
  568. gap: ${space(1)};
  569. border-radius: 4px;
  570. background-color: ${p => p.theme.surface200};
  571. margin: 2px;
  572. `;
  573. const StyledQuestionTooltip = styled(QuestionTooltip)`
  574. margin-left: ${space(0.5)};
  575. `;
  576. const StyledPre = styled('pre')`
  577. margin: 0 !important;
  578. background-color: transparent !important;
  579. `;
  580. const TableRowButtonContainer = styled('div')`
  581. padding: 8px 10px;
  582. `;
  583. const ValueTd = styled('td')`
  584. position: relative;
  585. `;
  586. function getThreadIdFromNode(
  587. node: TraceTreeNode<TraceTree.NodeValue>,
  588. transaction: EventTransaction | undefined
  589. ): string | undefined {
  590. if (isSpanNode(node) && node.value.data?.['thread.id']) {
  591. return node.value.data['thread.id'];
  592. }
  593. if (transaction) {
  594. return transaction.contexts?.trace?.data?.['thread.id'];
  595. }
  596. return undefined;
  597. }
  598. // Renders the dropdown menu list at the root trace drawer content container level, to prevent
  599. // being stacked under other content.
  600. function DropdownMenuWithPortal(props: DropdownMenuProps) {
  601. const drawerContainerRef = useDrawerContainerRef();
  602. return (
  603. <DropdownMenu
  604. {...props}
  605. usePortal={!!drawerContainerRef}
  606. portalContainerRef={drawerContainerRef}
  607. />
  608. );
  609. }
  610. function TypeSafeBoolean<T>(value: T | null | undefined): value is NonNullable<T> {
  611. return value !== null && value !== undefined;
  612. }
  613. function PanelPositionDropDown({organization}: {organization: Organization}) {
  614. const traceState = useTraceState();
  615. const traceDispatch = useTraceStateDispatch();
  616. const options: MenuItemProps[] = [];
  617. const layoutOptions = traceState.preferences.drawer.layoutOptions;
  618. if (layoutOptions.includes('drawer left')) {
  619. options.push({
  620. key: 'drawer-left',
  621. onAction: () => {
  622. traceAnalytics.trackLayoutChange('drawer left', organization);
  623. traceDispatch({type: 'set layout', payload: 'drawer left'});
  624. },
  625. leadingItems: <IconPanel direction="left" size="xs" />,
  626. label: t('Left'),
  627. disabled: traceState.preferences.layout === 'drawer left',
  628. });
  629. }
  630. if (layoutOptions.includes('drawer right')) {
  631. options.push({
  632. key: 'drawer-right',
  633. onAction: () => {
  634. traceAnalytics.trackLayoutChange('drawer right', organization);
  635. traceDispatch({type: 'set layout', payload: 'drawer right'});
  636. },
  637. leadingItems: <IconPanel direction="right" size="xs" />,
  638. label: t('Right'),
  639. disabled: traceState.preferences.layout === 'drawer right',
  640. });
  641. }
  642. if (layoutOptions.includes('drawer bottom')) {
  643. options.push({
  644. key: 'drawer-bottom',
  645. onAction: () => {
  646. traceAnalytics.trackLayoutChange('drawer bottom', organization);
  647. traceDispatch({type: 'set layout', payload: 'drawer bottom'});
  648. },
  649. leadingItems: <IconPanel direction="down" size="xs" />,
  650. label: t('Bottom'),
  651. disabled: traceState.preferences.layout === 'drawer bottom',
  652. });
  653. }
  654. return (
  655. <DropdownMenu
  656. size="sm"
  657. items={options}
  658. menuTitle={<div>{t('Panel Position')}</div>}
  659. trigger={triggerProps => (
  660. <Tooltip title={t('Panel Position')}>
  661. <ActionButton
  662. {...triggerProps}
  663. size="xs"
  664. aria-label={t('Panel position')}
  665. icon={<IconPanel direction="right" size="xs" />}
  666. />
  667. </Tooltip>
  668. )}
  669. />
  670. );
  671. }
  672. function NodeActions(props: {
  673. node: TraceTreeNode<any>;
  674. onTabScrollToNode: (
  675. node:
  676. | TraceTreeNode<any>
  677. | ParentAutogroupNode
  678. | SiblingAutogroupNode
  679. | MissingInstrumentationNode
  680. ) => void;
  681. organization: Organization;
  682. eventSize?: number | undefined;
  683. }) {
  684. const hasNewTraceUi = useHasTraceNewUi();
  685. const organization = useOrganization();
  686. const params = useParams<{traceSlug?: string}>();
  687. const {data: transaction} = useTransaction({
  688. node: isTransactionNode(props.node) ? props.node : null,
  689. organization,
  690. });
  691. const profilerId: string = useMemo(() => {
  692. if (isTransactionNode(props.node)) {
  693. return props.node.value.profiler_id;
  694. }
  695. if (isSpanNode(props.node)) {
  696. return props.node.value.sentry_tags?.profiler_id ?? '';
  697. }
  698. return '';
  699. }, [props]);
  700. const profileLink = makeTraceContinuousProfilingLink(props.node, profilerId, {
  701. orgSlug: props.organization.slug,
  702. projectSlug: props.node.metadata.project_slug ?? '',
  703. traceId: params.traceSlug ?? '',
  704. threadId: getThreadIdFromNode(props.node, transaction),
  705. });
  706. if (!hasNewTraceUi) {
  707. return (
  708. <LegacyNodeActions
  709. {...props}
  710. profileLink={profileLink}
  711. profilerId={profilerId}
  712. transaction={transaction}
  713. />
  714. );
  715. }
  716. return (
  717. <ActionWrapper>
  718. <Tooltip title={t('Show in view')}>
  719. <ActionButton
  720. onClick={_e => {
  721. traceAnalytics.trackShowInView(props.organization);
  722. props.onTabScrollToNode(props.node);
  723. }}
  724. size="xs"
  725. aria-label={t('Show in view')}
  726. icon={<IconFocus size="xs" />}
  727. />
  728. </Tooltip>
  729. {isTransactionNode(props.node) ? (
  730. <Tooltip title={t('JSON')}>
  731. <ActionButton
  732. onClick={() => traceAnalytics.trackViewEventJSON(props.organization)}
  733. href={`/api/0/projects/${props.organization.slug}/${props.node.value.project_slug}/events/${props.node.value.event_id}/json/`}
  734. size="xs"
  735. aria-label={t('JSON')}
  736. icon={<IconJson size="xs" />}
  737. />
  738. </Tooltip>
  739. ) : null}
  740. {organization.features.includes('continuous-profiling-ui') && !!profileLink ? (
  741. <Tooltip title={t('Continuous Profile')}>
  742. <ActionButton
  743. size="xs"
  744. aria-label={t('Continuous Profile')}
  745. icon={<IconProfiling size="xs" />}
  746. />
  747. </Tooltip>
  748. ) : null}
  749. <PanelPositionDropDown organization={organization} />
  750. </ActionWrapper>
  751. );
  752. }
  753. const ActionButton = styled(Button)`
  754. border: none;
  755. background-color: transparent;
  756. box-shadow: none;
  757. transition: none !important;
  758. opacity: 0.8;
  759. height: 24px;
  760. max-height: 24px;
  761. &:hover {
  762. border: none;
  763. background-color: transparent;
  764. box-shadow: none;
  765. opacity: 1;
  766. }
  767. `;
  768. const ActionWrapper = styled('div')`
  769. display: flex;
  770. align-items: center;
  771. gap: ${space(0.25)};
  772. `;
  773. function LegacyNodeActions(props: {
  774. node: TraceTreeNode<any>;
  775. onTabScrollToNode: (
  776. node:
  777. | TraceTreeNode<any>
  778. | ParentAutogroupNode
  779. | SiblingAutogroupNode
  780. | MissingInstrumentationNode
  781. ) => void;
  782. profileLink: LocationDescriptor | null;
  783. profilerId: string;
  784. transaction: EventTransaction | undefined;
  785. eventSize?: number | undefined;
  786. }) {
  787. const navigate = useNavigate();
  788. const organization = useOrganization();
  789. const items = useMemo((): MenuItemProps[] => {
  790. const showInView: MenuItemProps = {
  791. key: 'show-in-view',
  792. label: t('Show in View'),
  793. onAction: () => {
  794. traceAnalytics.trackShowInView(organization);
  795. props.onTabScrollToNode(props.node);
  796. },
  797. };
  798. const eventId =
  799. props.node.metadata.event_id ??
  800. TraceTree.ParentTransaction(props.node)?.metadata.event_id;
  801. const projectSlug =
  802. props.node.metadata.project_slug ??
  803. TraceTree.ParentTransaction(props.node)?.metadata.project_slug;
  804. const eventSize = props.eventSize;
  805. const jsonDetails: MenuItemProps = {
  806. key: 'json-details',
  807. onAction: () => {
  808. traceAnalytics.trackViewEventJSON(organization);
  809. window.open(
  810. `/api/0/projects/${organization.slug}/${projectSlug}/events/${eventId}/json/`,
  811. '_blank'
  812. );
  813. },
  814. label:
  815. t('JSON') +
  816. (typeof eventSize === 'number' ? ` (${formatBytesBase10(eventSize, 0)})` : ''),
  817. };
  818. const continuousProfileLink: MenuItemProps | null =
  819. organization.features.includes('continuous-profiling-ui') && !!props.profileLink
  820. ? {
  821. key: 'continuous-profile',
  822. onAction: () => {
  823. traceAnalytics.trackViewContinuousProfile(organization);
  824. navigate(props.profileLink!);
  825. },
  826. label: t('Continuous Profile'),
  827. }
  828. : null;
  829. if (isTransactionNode(props.node)) {
  830. return [showInView, jsonDetails, continuousProfileLink].filter(TypeSafeBoolean);
  831. }
  832. if (isSpanNode(props.node)) {
  833. return [showInView, continuousProfileLink].filter(TypeSafeBoolean);
  834. }
  835. if (isMissingInstrumentationNode(props.node)) {
  836. return [showInView, continuousProfileLink].filter(TypeSafeBoolean);
  837. }
  838. if (isTraceErrorNode(props.node)) {
  839. return [showInView, continuousProfileLink].filter(TypeSafeBoolean);
  840. }
  841. if (isRootNode(props.node)) {
  842. return [showInView];
  843. }
  844. if (isAutogroupedNode(props.node)) {
  845. return [showInView];
  846. }
  847. return [showInView];
  848. }, [props, navigate, organization]);
  849. return (
  850. <ActionsContainer>
  851. <Actions className="Actions">
  852. {organization.features.includes('continuous-profiling-ui') &&
  853. !!props.profileLink ? (
  854. <LinkButton size="xs" to={props.profileLink}>
  855. {t('Continuous Profile')}
  856. </LinkButton>
  857. ) : null}
  858. <Button
  859. size="xs"
  860. onClick={_e => {
  861. traceAnalytics.trackShowInView(organization);
  862. props.onTabScrollToNode(props.node);
  863. }}
  864. >
  865. {t('Show in view')}
  866. </Button>
  867. {isTransactionNode(props.node) ? (
  868. <LinkButton
  869. size="xs"
  870. icon={<IconOpen />}
  871. onClick={() => traceAnalytics.trackViewEventJSON(organization)}
  872. href={`/api/0/projects/${organization.slug}/${props.node.value.project_slug}/events/${props.node.value.event_id}/json/`}
  873. external
  874. >
  875. {t('JSON')} (<FileSize bytes={props.eventSize ?? 0} />)
  876. </LinkButton>
  877. ) : null}
  878. </Actions>
  879. <DropdownMenuWithPortal
  880. items={items}
  881. className="DropdownMenu"
  882. position="bottom-end"
  883. trigger={triggerProps => (
  884. <ActionsButtonTrigger size="xs" {...triggerProps}>
  885. {t('Actions')}
  886. <IconChevron direction="down" size="xs" />
  887. </ActionsButtonTrigger>
  888. )}
  889. />
  890. </ActionsContainer>
  891. );
  892. }
  893. const ActionsButtonTrigger = styled(Button)`
  894. svg {
  895. margin-left: ${space(0.5)};
  896. width: 10px;
  897. height: 10px;
  898. }
  899. `;
  900. const ActionsContainer = styled('div')`
  901. display: flex;
  902. justify-content: end;
  903. align-items: center;
  904. gap: ${space(1)};
  905. `;
  906. function EventTags({projectSlug, event}: {event: Event; projectSlug: string}) {
  907. const hasNewTraceUi = useHasTraceNewUi();
  908. if (!hasNewTraceUi) {
  909. return <LegacyEventTags event={event} projectSlug={projectSlug} />;
  910. }
  911. return <EventTagsDataSection event={event} projectSlug={projectSlug} />;
  912. }
  913. function LegacyEventTags({projectSlug, event}: {event: Event; projectSlug: string}) {
  914. return (
  915. <LazyRender {...TraceDrawerComponents.LAZY_RENDER_PROPS} containerHeight={200}>
  916. <TagsWrapper>
  917. <EventTagsDataSection event={event} projectSlug={projectSlug} />
  918. </TagsWrapper>
  919. </LazyRender>
  920. );
  921. }
  922. const TagsWrapper = styled('div')`
  923. h3 {
  924. color: ${p => p.theme.textColor};
  925. }
  926. `;
  927. export type SectionCardKeyValueList = KeyValueListData;
  928. function SectionCard({
  929. items,
  930. title,
  931. disableTruncate,
  932. sortAlphabetically = false,
  933. itemProps = {},
  934. }: {
  935. items: SectionCardKeyValueList;
  936. title: React.ReactNode;
  937. disableTruncate?: boolean;
  938. itemProps?: Partial<KeyValueDataContentProps>;
  939. sortAlphabetically?: boolean;
  940. }) {
  941. const contentItems = items.map(item => ({item, ...itemProps}));
  942. return (
  943. <CardWrapper>
  944. <KeyValueData.Card
  945. title={title}
  946. contentItems={contentItems}
  947. sortAlphabetically={sortAlphabetically}
  948. truncateLength={disableTruncate ? Infinity : 5}
  949. />
  950. </CardWrapper>
  951. );
  952. }
  953. // This is trace-view specific styling. The card is rendered in a number of different places
  954. // with tests failing otherwise, since @container queries are not supported by the version of
  955. // jsdom currently used by jest.
  956. const CardWrapper = styled('div')`
  957. ${CardPanel} {
  958. container-type: inline-size;
  959. }
  960. ${Subject} {
  961. @container (width < 350px) {
  962. max-width: 200px;
  963. }
  964. }
  965. `;
  966. function SectionCardGroup({children}: {children: React.ReactNode}) {
  967. return <KeyValueData.Container>{children}</KeyValueData.Container>;
  968. }
  969. function CopyableCardValueWithLink({
  970. value,
  971. linkTarget,
  972. linkText,
  973. onClick,
  974. }: {
  975. value: React.ReactNode;
  976. linkTarget?: LocationDescriptor;
  977. linkText?: string;
  978. onClick?: () => void;
  979. }) {
  980. return (
  981. <CardValueContainer>
  982. <CardValueText>
  983. {value}
  984. {typeof value === 'string' ? (
  985. <StyledCopyToClipboardButton
  986. borderless
  987. size="zero"
  988. iconSize="xs"
  989. text={value}
  990. />
  991. ) : null}
  992. </CardValueText>
  993. {linkTarget && linkTarget ? (
  994. <Link to={linkTarget} onClick={onClick}>
  995. {linkText}
  996. </Link>
  997. ) : null}
  998. </CardValueContainer>
  999. );
  1000. }
  1001. function TraceDataSection({event}: {event: EventTransaction}) {
  1002. const traceData = event.contexts.trace?.data;
  1003. if (!traceData) {
  1004. return null;
  1005. }
  1006. return (
  1007. <SectionCard
  1008. items={Object.entries(traceData).map(([key, value]) => ({
  1009. key,
  1010. subject: key,
  1011. value,
  1012. }))}
  1013. title={t('Trace Data')}
  1014. />
  1015. );
  1016. }
  1017. const StyledCopyToClipboardButton = styled(CopyToClipboardButton)`
  1018. transform: translateY(2px);
  1019. `;
  1020. const CardValueContainer = styled(FlexBox)`
  1021. justify-content: space-between;
  1022. gap: ${space(1)};
  1023. flex-wrap: wrap;
  1024. `;
  1025. const CardValueText = styled('span')`
  1026. overflow-wrap: anywhere;
  1027. `;
  1028. export const CardContentSubject = styled('div')`
  1029. grid-column: span 1;
  1030. font-family: ${p => p.theme.text.familyMono};
  1031. word-wrap: break-word;
  1032. `;
  1033. const TraceDrawerComponents = {
  1034. DetailContainer,
  1035. FlexBox,
  1036. Title: TitleWithTestId,
  1037. Type,
  1038. TitleOp,
  1039. HeaderContainer,
  1040. LegacyHeaderContainer,
  1041. Highlights,
  1042. Actions,
  1043. NodeActions,
  1044. Table,
  1045. IconTitleWrapper,
  1046. IconBorder,
  1047. TitleText,
  1048. LegacyTitleText,
  1049. Duration,
  1050. TableRow,
  1051. LAZY_RENDER_PROPS,
  1052. TableRowButtonContainer,
  1053. TableValueRow,
  1054. IssuesLink,
  1055. SectionCard,
  1056. CopyableCardValueWithLink,
  1057. EventTags,
  1058. SubtitleWithCopyButton,
  1059. TraceDataSection,
  1060. SectionCardGroup,
  1061. DropdownMenuWithPortal,
  1062. };
  1063. export {TraceDrawerComponents};