styles.tsx 33 KB

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