styles.tsx 17 KB

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