styles.tsx 17 KB

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