styles.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  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 {DataSection} from 'sentry/components/events/styles';
  13. import FileSize from 'sentry/components/fileSize';
  14. import KeyValueData, {
  15. CardPanel,
  16. type KeyValueDataContentProps,
  17. Subject,
  18. } from 'sentry/components/keyValueData';
  19. import {LazyRender, type LazyRenderProps} from 'sentry/components/lazyRender';
  20. import Link from 'sentry/components/links/link';
  21. import QuestionTooltip from 'sentry/components/questionTooltip';
  22. import {Tooltip} from 'sentry/components/tooltip';
  23. import {IconChevron, IconOpen} from 'sentry/icons';
  24. import {t, tct} from 'sentry/locale';
  25. import {space} from 'sentry/styles/space';
  26. import type {Event, EventTransaction} from 'sentry/types/event';
  27. import type {KeyValueListData} from 'sentry/types/group';
  28. import type {Organization} from 'sentry/types/organization';
  29. import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10';
  30. import getDuration from 'sentry/utils/duration/getDuration';
  31. import type {ColorOrAlias} from 'sentry/utils/theme';
  32. import useOrganization from 'sentry/utils/useOrganization';
  33. import {useParams} from 'sentry/utils/useParams';
  34. import {
  35. isAutogroupedNode,
  36. isMissingInstrumentationNode,
  37. isNoDataNode,
  38. isRootNode,
  39. isSpanNode,
  40. isTraceErrorNode,
  41. isTransactionNode,
  42. } from 'sentry/views/performance/newTraceDetails/guards';
  43. import {traceAnalytics} from 'sentry/views/performance/newTraceDetails/traceAnalytics';
  44. import {useTransaction} from 'sentry/views/performance/newTraceDetails/traceApi/useTransaction';
  45. import {useDrawerContainerRef} from 'sentry/views/performance/newTraceDetails/traceDrawer/details/drawerContainerRefContext';
  46. import {makeTraceContinuousProfilingLink} from 'sentry/views/performance/newTraceDetails/traceDrawer/traceProfilingLink';
  47. import type {
  48. MissingInstrumentationNode,
  49. NoDataNode,
  50. ParentAutogroupNode,
  51. SiblingAutogroupNode,
  52. TraceTree,
  53. TraceTreeNode,
  54. } from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
  55. const DetailContainer = styled('div')`
  56. display: flex;
  57. flex-direction: column;
  58. gap: ${space(2)};
  59. padding: ${space(1)};
  60. ${DataSection} {
  61. padding: 0;
  62. }
  63. `;
  64. const FlexBox = styled('div')`
  65. display: flex;
  66. align-items: center;
  67. `;
  68. const Actions = styled(FlexBox)`
  69. gap: ${space(0.5)};
  70. justify-content: end;
  71. width: 100%;
  72. `;
  73. const Title = styled(FlexBox)`
  74. gap: ${space(1)};
  75. flex-grow: 1;
  76. overflow: hidden;
  77. > span {
  78. min-width: 30px;
  79. }
  80. `;
  81. const TitleText = styled('div')`
  82. ${p => p.theme.overflowEllipsis}
  83. `;
  84. function TitleWithTestId(props: PropsWithChildren<{}>) {
  85. return <Title data-test-id="trace-drawer-title">{props.children}</Title>;
  86. }
  87. function TitleOp({text}: {text: string}) {
  88. return (
  89. <Tooltip
  90. title={
  91. <Fragment>
  92. {text}
  93. <CopyToClipboardButton
  94. borderless
  95. size="zero"
  96. iconSize="xs"
  97. text={text}
  98. tooltipProps={{disabled: true}}
  99. />
  100. </Fragment>
  101. }
  102. showOnlyOnOverflow
  103. isHoverable
  104. >
  105. <TitleOpText>{text}</TitleOpText>
  106. </Tooltip>
  107. );
  108. }
  109. const Type = styled('div')`
  110. font-size: ${p => p.theme.fontSizeSmall};
  111. `;
  112. const TitleOpText = styled('div')`
  113. font-size: 15px;
  114. font-weight: ${p => p.theme.fontWeightBold};
  115. ${p => p.theme.overflowEllipsis}
  116. `;
  117. const Table = styled('table')`
  118. margin-bottom: 0 !important;
  119. td {
  120. overflow: hidden;
  121. }
  122. `;
  123. const IconTitleWrapper = styled(FlexBox)`
  124. gap: ${space(1)};
  125. min-width: 30px;
  126. `;
  127. const IconBorder = styled('div')<{backgroundColor: string; errored?: boolean}>`
  128. background-color: ${p => p.backgroundColor};
  129. border-radius: ${p => p.theme.borderRadius};
  130. padding: 0;
  131. display: flex;
  132. align-items: center;
  133. justify-content: center;
  134. width: 30px;
  135. height: 30px;
  136. min-width: 30px;
  137. svg {
  138. fill: ${p => p.theme.white};
  139. width: 14px;
  140. height: 14px;
  141. }
  142. `;
  143. const HeaderContainer = styled(FlexBox)`
  144. justify-content: space-between;
  145. gap: ${space(3)};
  146. container-type: inline-size;
  147. @container (max-width: 780px) {
  148. .DropdownMenu {
  149. display: block;
  150. }
  151. .Actions {
  152. display: none;
  153. }
  154. }
  155. @container (min-width: 781px) {
  156. .DropdownMenu {
  157. display: none;
  158. }
  159. }
  160. `;
  161. const DURATION_COMPARISON_STATUS_COLORS: {
  162. equal: {light: ColorOrAlias; normal: ColorOrAlias};
  163. faster: {light: ColorOrAlias; normal: ColorOrAlias};
  164. slower: {light: ColorOrAlias; normal: ColorOrAlias};
  165. } = {
  166. faster: {
  167. light: 'green100',
  168. normal: 'green300',
  169. },
  170. slower: {
  171. light: 'red100',
  172. normal: 'red300',
  173. },
  174. equal: {
  175. light: 'gray100',
  176. normal: 'gray300',
  177. },
  178. };
  179. const MIN_PCT_DURATION_DIFFERENCE = 10;
  180. type DurationProps = {
  181. baseline: number | undefined;
  182. duration: number;
  183. baseDescription?: string;
  184. ratio?: number;
  185. };
  186. function Duration(props: DurationProps) {
  187. if (typeof props.duration !== 'number' || Number.isNaN(props.duration)) {
  188. return <DurationContainer>{t('unknown')}</DurationContainer>;
  189. }
  190. if (props.baseline === undefined || props.baseline === 0) {
  191. return <DurationContainer>{getDuration(props.duration, 2, true)}</DurationContainer>;
  192. }
  193. const delta = props.duration - props.baseline;
  194. const deltaPct = Math.round(Math.abs((delta / props.baseline) * 100));
  195. const status = delta > 0 ? 'slower' : delta < 0 ? 'faster' : 'equal';
  196. const formattedBaseDuration = (
  197. <Tooltip
  198. title={props.baseDescription}
  199. showUnderline
  200. underlineColor={DURATION_COMPARISON_STATUS_COLORS[status].normal}
  201. >
  202. {getDuration(props.baseline, 2, true)}
  203. </Tooltip>
  204. );
  205. const deltaText =
  206. status === 'equal'
  207. ? tct(`equal to the avg of [formattedBaseDuration]`, {
  208. formattedBaseDuration,
  209. })
  210. : status === 'faster'
  211. ? tct(`[deltaPct] faster than the avg of [formattedBaseDuration]`, {
  212. formattedBaseDuration,
  213. deltaPct: `${deltaPct}%`,
  214. })
  215. : tct(`[deltaPct] slower than the avg of [formattedBaseDuration]`, {
  216. formattedBaseDuration,
  217. deltaPct: `${deltaPct}%`,
  218. });
  219. return (
  220. <Fragment>
  221. <DurationContainer>
  222. {getDuration(props.duration, 2, true)}{' '}
  223. {props.ratio ? `(${(props.ratio * 100).toFixed()}%)` : null}
  224. </DurationContainer>
  225. {deltaPct >= MIN_PCT_DURATION_DIFFERENCE ? (
  226. <Comparison status={status}>{deltaText}</Comparison>
  227. ) : null}
  228. </Fragment>
  229. );
  230. }
  231. function TableRow({
  232. title,
  233. keep,
  234. children,
  235. prefix,
  236. extra = null,
  237. toolTipText,
  238. }: {
  239. children: React.ReactNode;
  240. title: JSX.Element | string | null;
  241. extra?: React.ReactNode;
  242. keep?: boolean;
  243. prefix?: JSX.Element;
  244. toolTipText?: string;
  245. }) {
  246. if (!keep && !children) {
  247. return null;
  248. }
  249. return (
  250. <tr>
  251. <td className="key">
  252. <Flex>
  253. {prefix}
  254. {title}
  255. {toolTipText ? <StyledQuestionTooltip size="xs" title={toolTipText} /> : null}
  256. </Flex>
  257. </td>
  258. <ValueTd className="value">
  259. <TableValueRow>
  260. <StyledPre>
  261. <span className="val-string">{children}</span>
  262. </StyledPre>
  263. <TableRowButtonContainer>{extra}</TableRowButtonContainer>
  264. </TableValueRow>
  265. </ValueTd>
  266. </tr>
  267. );
  268. }
  269. function IssuesLink({
  270. node,
  271. children,
  272. }: {
  273. children: React.ReactNode;
  274. node: TraceTreeNode<TraceTree.NodeValue>;
  275. }) {
  276. const organization = useOrganization();
  277. const params = useParams<{traceSlug?: string}>();
  278. const traceSlug = params.traceSlug?.trim() ?? '';
  279. return (
  280. <Link
  281. to={{
  282. pathname: `/organizations/${organization.slug}/issues/`,
  283. query: {
  284. query: `trace:${traceSlug}`,
  285. start: new Date(node.space[0]).toISOString(),
  286. end: new Date(node.space[0] + node.space[1]).toISOString(),
  287. // If we don't pass the project param, the issues page will filter by the last selected project.
  288. // Traces can have multiple projects, so we query issues by all projects and rely on our search query to filter the results.
  289. project: -1,
  290. },
  291. }}
  292. >
  293. {children}
  294. </Link>
  295. );
  296. }
  297. const LAZY_RENDER_PROPS: Partial<LazyRenderProps> = {
  298. observerOptions: {rootMargin: '50px'},
  299. };
  300. const DurationContainer = styled('span')`
  301. font-weight: ${p => p.theme.fontWeightBold};
  302. margin-right: ${space(1)};
  303. `;
  304. const Comparison = styled('span')<{status: 'faster' | 'slower' | 'equal'}>`
  305. color: ${p => p.theme[DURATION_COMPARISON_STATUS_COLORS[p.status].normal]};
  306. `;
  307. const Flex = styled('div')`
  308. display: flex;
  309. align-items: center;
  310. `;
  311. const TableValueRow = styled('div')`
  312. display: grid;
  313. grid-template-columns: auto min-content;
  314. gap: ${space(1)};
  315. border-radius: 4px;
  316. background-color: ${p => p.theme.surface200};
  317. margin: 2px;
  318. `;
  319. const StyledQuestionTooltip = styled(QuestionTooltip)`
  320. margin-left: ${space(0.5)};
  321. `;
  322. const StyledPre = styled('pre')`
  323. margin: 0 !important;
  324. background-color: transparent !important;
  325. `;
  326. const TableRowButtonContainer = styled('div')`
  327. padding: 8px 10px;
  328. `;
  329. const ValueTd = styled('td')`
  330. position: relative;
  331. `;
  332. function getThreadIdFromNode(
  333. node: TraceTreeNode<TraceTree.NodeValue>,
  334. transaction: EventTransaction | undefined
  335. ): string | undefined {
  336. if (isSpanNode(node) && node.value.data?.['thread.id']) {
  337. return node.value.data['thread.id'];
  338. }
  339. if (transaction) {
  340. return transaction.contexts?.trace?.data?.['thread.id'];
  341. }
  342. return undefined;
  343. }
  344. // Renders the dropdown menu list at the root trace drawer content container level, to prevent
  345. // being stacked under other content.
  346. function DropdownMenuWithPortal(props: DropdownMenuProps) {
  347. const drawerContainerRef = useDrawerContainerRef();
  348. return (
  349. <DropdownMenu
  350. {...props}
  351. usePortal={!!drawerContainerRef}
  352. portalContainerRef={drawerContainerRef}
  353. />
  354. );
  355. }
  356. function NodeActions(props: {
  357. node: TraceTreeNode<any>;
  358. onTabScrollToNode: (
  359. node:
  360. | TraceTreeNode<any>
  361. | ParentAutogroupNode
  362. | SiblingAutogroupNode
  363. | NoDataNode
  364. | MissingInstrumentationNode
  365. ) => void;
  366. organization: Organization;
  367. eventSize?: number | undefined;
  368. }) {
  369. const organization = useOrganization();
  370. const items = useMemo(() => {
  371. const showInView: MenuItemProps = {
  372. key: 'show-in-view',
  373. label: t('Show in View'),
  374. onAction: () => {
  375. traceAnalytics.trackShowInView(props.organization);
  376. props.onTabScrollToNode(props.node);
  377. },
  378. };
  379. const eventId =
  380. props.node.metadata.event_id ?? props.node.parent_transaction?.metadata.event_id;
  381. const projectSlug =
  382. props.node.metadata.project_slug ??
  383. props.node.parent_transaction?.metadata.project_slug;
  384. const eventSize = props.eventSize;
  385. const jsonDetails: MenuItemProps = {
  386. key: 'json-details',
  387. onAction: () => {
  388. traceAnalytics.trackViewEventJSON(props.organization);
  389. window.open(
  390. `/api/0/projects/${props.organization.slug}/${projectSlug}/events/${eventId}/json/`,
  391. '_blank'
  392. );
  393. },
  394. label:
  395. t('JSON') +
  396. (typeof eventSize === 'number' ? ` (${formatBytesBase10(eventSize, 0)})` : ''),
  397. };
  398. if (isTransactionNode(props.node)) {
  399. return [showInView, jsonDetails];
  400. }
  401. if (isSpanNode(props.node)) {
  402. return [showInView];
  403. }
  404. if (isMissingInstrumentationNode(props.node)) {
  405. return [showInView];
  406. }
  407. if (isTraceErrorNode(props.node)) {
  408. return [showInView];
  409. }
  410. if (isRootNode(props.node)) {
  411. return [showInView];
  412. }
  413. if (isAutogroupedNode(props.node)) {
  414. return [showInView];
  415. }
  416. if (isNoDataNode(props.node)) {
  417. return [showInView];
  418. }
  419. return [showInView];
  420. }, [props]);
  421. const profilerId = useMemo(() => {
  422. if (isTransactionNode(props.node)) {
  423. return props.node.value.profiler_id;
  424. }
  425. if (isSpanNode(props.node)) {
  426. return props.node.value.sentry_tags?.profiler_id ?? '';
  427. }
  428. return '';
  429. }, [props]);
  430. const {data: transaction} = useTransaction({
  431. node: isTransactionNode(props.node) ? props.node : null,
  432. organization,
  433. });
  434. const params = useParams<{traceSlug?: string}>();
  435. const profileLink = makeTraceContinuousProfilingLink(props.node, profilerId, {
  436. orgSlug: props.organization.slug,
  437. projectSlug: props.node.metadata.project_slug ?? '',
  438. traceId: params.traceSlug ?? '',
  439. threadId: getThreadIdFromNode(props.node, transaction),
  440. });
  441. return (
  442. <ActionsContainer>
  443. <Actions className="Actions">
  444. {organization.features.includes('continuous-profiling-ui') && !!profileLink ? (
  445. <LinkButton size="xs" to={profileLink}>
  446. {t('Continuous Profile')}
  447. </LinkButton>
  448. ) : null}
  449. <Button
  450. size="xs"
  451. onClick={_e => {
  452. traceAnalytics.trackShowInView(props.organization);
  453. props.onTabScrollToNode(props.node);
  454. }}
  455. >
  456. {t('Show in view')}
  457. </Button>
  458. {isTransactionNode(props.node) ? (
  459. <LinkButton
  460. size="xs"
  461. icon={<IconOpen />}
  462. onClick={() => traceAnalytics.trackViewEventJSON(props.organization)}
  463. href={`/api/0/projects/${props.organization.slug}/${props.node.value.project_slug}/events/${props.node.value.event_id}/json/`}
  464. external
  465. >
  466. {t('JSON')} (<FileSize bytes={props.eventSize ?? 0} />)
  467. </LinkButton>
  468. ) : null}
  469. </Actions>
  470. <DropdownMenuWithPortal
  471. items={items}
  472. className="DropdownMenu"
  473. position="bottom-end"
  474. trigger={triggerProps => (
  475. <ActionsButtonTrigger size="xs" {...triggerProps}>
  476. {t('Actions')}
  477. <IconChevron direction="down" size="xs" />
  478. </ActionsButtonTrigger>
  479. )}
  480. />
  481. </ActionsContainer>
  482. );
  483. }
  484. const ActionsButtonTrigger = styled(Button)`
  485. svg {
  486. margin-left: ${space(0.5)};
  487. width: 10px;
  488. height: 10px;
  489. }
  490. `;
  491. const ActionsContainer = styled('div')`
  492. display: flex;
  493. justify-content: end;
  494. align-items: center;
  495. gap: ${space(1)};
  496. `;
  497. function EventTags({projectSlug, event}: {event: Event; projectSlug: string}) {
  498. return (
  499. <LazyRender {...TraceDrawerComponents.LAZY_RENDER_PROPS} containerHeight={200}>
  500. <TagsWrapper>
  501. <EventTagsDataSection event={event} projectSlug={projectSlug} />
  502. </TagsWrapper>
  503. </LazyRender>
  504. );
  505. }
  506. const TagsWrapper = styled('div')`
  507. h3 {
  508. color: ${p => p.theme.textColor};
  509. }
  510. `;
  511. export type SectionCardKeyValueList = KeyValueListData;
  512. function SectionCard({
  513. items,
  514. title,
  515. disableTruncate,
  516. sortAlphabetically = false,
  517. itemProps = {},
  518. }: {
  519. items: SectionCardKeyValueList;
  520. title: React.ReactNode;
  521. disableTruncate?: boolean;
  522. itemProps?: Partial<KeyValueDataContentProps>;
  523. sortAlphabetically?: boolean;
  524. }) {
  525. const contentItems = items.map(item => ({item, ...itemProps}));
  526. return (
  527. <CardWrapper>
  528. <KeyValueData.Card
  529. title={title}
  530. contentItems={contentItems}
  531. sortAlphabetically={sortAlphabetically}
  532. truncateLength={disableTruncate ? Infinity : 5}
  533. />
  534. </CardWrapper>
  535. );
  536. }
  537. // This is trace-view specific styling. The card is rendered in a number of different places
  538. // with tests failing otherwise, since @container queries are not supported by the version of
  539. // jsdom currently used by jest.
  540. const CardWrapper = styled('div')`
  541. ${CardPanel} {
  542. container-type: inline-size;
  543. }
  544. ${Subject} {
  545. @container (width < 350px) {
  546. max-width: 200px;
  547. }
  548. }
  549. `;
  550. function SectionCardGroup({children}: {children: React.ReactNode}) {
  551. return <KeyValueData.Container>{children}</KeyValueData.Container>;
  552. }
  553. function CopyableCardValueWithLink({
  554. value,
  555. linkTarget,
  556. linkText,
  557. onClick,
  558. }: {
  559. value: React.ReactNode;
  560. linkTarget?: LocationDescriptor;
  561. linkText?: string;
  562. onClick?: () => void;
  563. }) {
  564. return (
  565. <CardValueContainer>
  566. <CardValueText>
  567. {value}
  568. {typeof value === 'string' ? (
  569. <StyledCopyToClipboardButton
  570. borderless
  571. size="zero"
  572. iconSize="xs"
  573. text={value}
  574. />
  575. ) : null}
  576. </CardValueText>
  577. {linkTarget && linkTarget ? (
  578. <Link to={linkTarget} onClick={onClick}>
  579. {linkText}
  580. </Link>
  581. ) : null}
  582. </CardValueContainer>
  583. );
  584. }
  585. function TraceDataSection({event}: {event: EventTransaction}) {
  586. const traceData = event.contexts.trace?.data;
  587. if (!traceData) {
  588. return null;
  589. }
  590. return (
  591. <SectionCard
  592. items={Object.entries(traceData).map(([key, value]) => ({
  593. key,
  594. subject: key,
  595. value,
  596. }))}
  597. title={t('Trace Data')}
  598. />
  599. );
  600. }
  601. const StyledCopyToClipboardButton = styled(CopyToClipboardButton)`
  602. transform: translateY(2px);
  603. `;
  604. const CardValueContainer = styled(FlexBox)`
  605. justify-content: space-between;
  606. gap: ${space(1)};
  607. flex-wrap: wrap;
  608. `;
  609. const CardValueText = styled('span')`
  610. overflow-wrap: anywhere;
  611. `;
  612. export const CardContentSubject = styled('div')`
  613. grid-column: span 1;
  614. font-family: ${p => p.theme.text.familyMono};
  615. word-wrap: break-word;
  616. `;
  617. const TraceDrawerComponents = {
  618. DetailContainer,
  619. FlexBox,
  620. Title: TitleWithTestId,
  621. Type,
  622. TitleOp,
  623. HeaderContainer,
  624. Actions,
  625. NodeActions,
  626. Table,
  627. IconTitleWrapper,
  628. IconBorder,
  629. TitleText,
  630. Duration,
  631. TableRow,
  632. LAZY_RENDER_PROPS,
  633. TableRowButtonContainer,
  634. TableValueRow,
  635. IssuesLink,
  636. SectionCard,
  637. CopyableCardValueWithLink,
  638. EventTags,
  639. TraceDataSection,
  640. SectionCardGroup,
  641. DropdownMenuWithPortal,
  642. };
  643. export {TraceDrawerComponents};