styles.tsx 16 KB

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