styles.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. import {Fragment, type PropsWithChildren, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import * as qs from 'query-string';
  5. import {Button as CommonButton, LinkButton} from 'sentry/components/button';
  6. import {DataSection} from 'sentry/components/events/styles';
  7. import type {LazyRenderProps} from 'sentry/components/lazyRender';
  8. import Link from 'sentry/components/links/link';
  9. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  10. import {TransactionToProfileButton} from 'sentry/components/profiling/transactionToProfileButton';
  11. import QuestionTooltip from 'sentry/components/questionTooltip';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {t, tct} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {EventTransaction, Project} from 'sentry/types';
  16. import type {Organization} from 'sentry/types/organization';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  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. isSpanNode,
  27. isTraceErrorNode,
  28. isTransactionNode,
  29. } from 'sentry/views/performance/newTraceDetails/guards';
  30. import type {
  31. TraceTree,
  32. TraceTreeNode,
  33. } from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
  34. const DetailContainer = styled('div')`
  35. display: flex;
  36. flex-direction: column;
  37. gap: ${space(2)};
  38. padding: ${space(1)};
  39. ${DataSection} {
  40. padding: 0;
  41. }
  42. `;
  43. const FlexBox = styled('div')`
  44. display: flex;
  45. align-items: center;
  46. `;
  47. const Actions = styled(FlexBox)`
  48. gap: ${space(0.5)};
  49. flex-wrap: wrap;
  50. justify-content: end;
  51. `;
  52. const Title = styled(FlexBox)`
  53. gap: ${space(1)};
  54. flex: none;
  55. width: 50%;
  56. > span {
  57. min-width: 30px;
  58. }
  59. `;
  60. const TitleText = styled('div')`
  61. ${p => p.theme.overflowEllipsis}
  62. `;
  63. function TitleWithTestId(props: PropsWithChildren<{}>) {
  64. return <Title data-test-id="trace-drawer-title">{props.children}</Title>;
  65. }
  66. const Type = styled('div')`
  67. font-size: ${p => p.theme.fontSizeSmall};
  68. `;
  69. const TitleOp = styled('div')`
  70. font-size: 15px;
  71. font-weight: bold;
  72. ${p => p.theme.overflowEllipsis}
  73. `;
  74. const Table = styled('table')`
  75. margin-bottom: 0 !important;
  76. td {
  77. overflow: hidden;
  78. }
  79. `;
  80. const IconTitleWrapper = styled(FlexBox)`
  81. gap: ${space(1)};
  82. min-width: 30px;
  83. `;
  84. const IconBorder = styled('div')<{backgroundColor: string; errored?: boolean}>`
  85. background-color: ${p => p.backgroundColor};
  86. border-radius: ${p => p.theme.borderRadius};
  87. padding: 0;
  88. display: flex;
  89. align-items: center;
  90. justify-content: center;
  91. width: 30px;
  92. height: 30px;
  93. svg {
  94. fill: ${p => p.theme.white};
  95. width: 14px;
  96. height: 14px;
  97. }
  98. `;
  99. const Button = styled(CommonButton)`
  100. position: absolute;
  101. top: ${space(0.75)};
  102. right: ${space(0.5)};
  103. `;
  104. const HeaderContainer = styled(Title)`
  105. justify-content: space-between;
  106. overflow: hidden;
  107. width: 100%;
  108. `;
  109. interface EventDetailsLinkProps {
  110. node: TraceTreeNode<TraceTree.NodeValue>;
  111. organization: Organization;
  112. }
  113. function EventDetailsLink(props: EventDetailsLinkProps) {
  114. const params = useMemo((): {
  115. eventId: string | undefined;
  116. projectSlug: string | undefined;
  117. } => {
  118. const eventId = props.node.metadata.event_id;
  119. const projectSlug = props.node.metadata.project_slug;
  120. if (eventId && projectSlug) {
  121. return {eventId, projectSlug};
  122. }
  123. if (isSpanNode(props.node) || isAutogroupedNode(props.node)) {
  124. const parent = props.node.parent_transaction;
  125. if (parent?.metadata.event_id && parent?.metadata.project_slug) {
  126. return {
  127. eventId: parent.metadata.event_id,
  128. projectSlug: parent.metadata.project_slug,
  129. };
  130. }
  131. }
  132. return {eventId: undefined, projectSlug: undefined};
  133. }, [props.node]);
  134. const locationDescriptor = useMemo(() => {
  135. const query = {...qs.parse(location.search), legacy: 1};
  136. return {
  137. query: query,
  138. pathname: `/performance/${params.projectSlug}:${params.eventId}/`,
  139. hash: isSpanNode(props.node) ? `#span-${props.node.value.span_id}` : undefined,
  140. };
  141. }, [params.eventId, params.projectSlug, props.node]);
  142. return (
  143. <LinkButton
  144. disabled={!params.eventId || !params.projectSlug}
  145. title={
  146. !params.eventId || !params.projectSlug
  147. ? t('Event ID or Project Slug missing')
  148. : undefined
  149. }
  150. size="xs"
  151. to={locationDescriptor}
  152. onClick={() => {
  153. trackAnalytics('performance_views.trace_details.view_event_details', {
  154. organization: props.organization,
  155. });
  156. }}
  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 ProfileLink({
  359. event,
  360. project,
  361. query,
  362. }: {
  363. event: EventTransaction;
  364. project: Project | undefined;
  365. query?: Location['query'];
  366. }) {
  367. const profileId = event.contexts.profile?.profile_id || '';
  368. if (!profileId) {
  369. return null;
  370. }
  371. return profileId && project?.slug ? (
  372. <TraceDrawerComponents.TableRow
  373. title="Profile ID"
  374. extra={
  375. <TransactionToProfileButton
  376. size="xs"
  377. projectSlug={project.slug}
  378. event={event}
  379. query={query}
  380. >
  381. {t('View Profile')}
  382. </TransactionToProfileButton>
  383. }
  384. >
  385. {profileId}
  386. </TraceDrawerComponents.TableRow>
  387. ) : null;
  388. }
  389. const TraceDrawerComponents = {
  390. DetailContainer,
  391. FlexBox,
  392. Title: TitleWithTestId,
  393. Type,
  394. TitleOp,
  395. HeaderContainer,
  396. Actions,
  397. Table,
  398. IconTitleWrapper,
  399. IconBorder,
  400. EventDetailsLink,
  401. Button,
  402. TitleText,
  403. Duration,
  404. TableRow,
  405. LAZY_RENDER_PROPS,
  406. TableRowButtonContainer,
  407. TableValueRow,
  408. ProfileLink,
  409. IssuesLink,
  410. };
  411. export {TraceDrawerComponents};