styles.tsx 15 KB

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