styles.tsx 31 KB

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