styles.tsx 16 KB

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