content.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import debounce from 'lodash/debounce';
  5. import omit from 'lodash/omit';
  6. import {Alert} from 'sentry/components/alert';
  7. import {Button} from 'sentry/components/button';
  8. import Count from 'sentry/components/count';
  9. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  10. import * as Layout from 'sentry/components/layouts/thirds';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
  13. import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
  14. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  15. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  16. import Panel from 'sentry/components/panels/panel';
  17. import PanelHeader from 'sentry/components/panels/panelHeader';
  18. import PanelItem from 'sentry/components/panels/panelItem';
  19. import PerformanceDuration from 'sentry/components/performanceDuration';
  20. import {IconChevron} from 'sentry/icons/iconChevron';
  21. import {IconClose} from 'sentry/icons/iconClose';
  22. import {t, tct} from 'sentry/locale';
  23. import {space} from 'sentry/styles/space';
  24. import type {PageFilters} from 'sentry/types/core';
  25. import type {MRI} from 'sentry/types/metrics';
  26. import {browserHistory} from 'sentry/utils/browserHistory';
  27. import {getFormattedMQL} from 'sentry/utils/metrics';
  28. import {useApiQuery} from 'sentry/utils/queryClient';
  29. import {decodeInteger, decodeList, decodeScalar} from 'sentry/utils/queryString';
  30. import {useLocation} from 'sentry/utils/useLocation';
  31. import useOrganization from 'sentry/utils/useOrganization';
  32. import usePageFilters from 'sentry/utils/usePageFilters';
  33. import {type Field, FIELDS, SORTS} from './data';
  34. import {
  35. ProjectRenderer,
  36. SpanBreakdownSliceRenderer,
  37. SpanDescriptionRenderer,
  38. SpanIdRenderer,
  39. SpanTimeRenderer,
  40. TraceBreakdownContainer,
  41. TraceBreakdownRenderer,
  42. TraceIdRenderer,
  43. TraceIssuesRenderer,
  44. } from './fieldRenderers';
  45. import {TracesSearchBar} from './tracesSearchBar';
  46. import {getSecondaryNameFromSpan, getStylingSliceName, normalizeTraces} from './utils';
  47. const DEFAULT_PER_PAGE = 20;
  48. export function Content() {
  49. const location = useLocation();
  50. const queries = useMemo(() => {
  51. return decodeList(location.query.query);
  52. }, [location.query.query]);
  53. const limit = useMemo(() => {
  54. return decodeInteger(location.query.perPage, DEFAULT_PER_PAGE);
  55. }, [location.query.perPage]);
  56. const removeMetric = useCallback(() => {
  57. browserHistory.push({
  58. ...location,
  59. query: omit(location.query, [
  60. 'mri',
  61. 'metricsOp',
  62. 'metricsQuery',
  63. 'metricsMax',
  64. 'metricsMin',
  65. ]),
  66. });
  67. }, [location]);
  68. const metricsMax = decodeScalar(location.query.metricsMax);
  69. const metricsMin = decodeScalar(location.query.metricsMin);
  70. const metricsOp = decodeScalar(location.query.metricsOp);
  71. const metricsQuery = decodeScalar(location.query.metricsQuery);
  72. const mri = decodeScalar(location.query.mri);
  73. const handleSearch = useCallback(
  74. (searchIndex: number, searchQuery: string) => {
  75. const newQueries = [...queries];
  76. if (newQueries.length === 0) {
  77. // In the odd case someone wants to add search bars before any query has been made, we add both the default one shown and a new one.
  78. newQueries[0] = '';
  79. }
  80. newQueries[searchIndex] = searchQuery;
  81. browserHistory.push({
  82. ...location,
  83. query: {
  84. ...location.query,
  85. cursor: undefined,
  86. query: typeof searchQuery === 'string' ? newQueries : queries,
  87. },
  88. });
  89. },
  90. [location, queries]
  91. );
  92. const handleClearSearch = useCallback(
  93. (searchIndex: number) => {
  94. const newQueries = [...queries];
  95. if (typeof newQueries[searchIndex] !== undefined) {
  96. delete newQueries[searchIndex];
  97. browserHistory.push({
  98. ...location,
  99. query: {
  100. ...location.query,
  101. cursor: undefined,
  102. query: newQueries,
  103. },
  104. });
  105. return true;
  106. }
  107. return false;
  108. },
  109. [location, queries]
  110. );
  111. const hasMetric = metricsOp && mri;
  112. const traces = useTraces<Field>({
  113. fields: [
  114. ...FIELDS,
  115. ...SORTS.map(field =>
  116. field.startsWith('-') ? (field.substring(1) as Field) : (field as Field)
  117. ),
  118. ],
  119. limit,
  120. query: queries,
  121. sort: SORTS,
  122. mri: hasMetric ? mri : undefined,
  123. metricsMax: hasMetric ? metricsMax : undefined,
  124. metricsMin: hasMetric ? metricsMin : undefined,
  125. metricsOp: hasMetric ? metricsOp : undefined,
  126. metricsQuery: hasMetric ? metricsQuery : undefined,
  127. });
  128. const isLoading = traces.isFetching;
  129. const isError = !isLoading && traces.isError;
  130. const isEmpty = !isLoading && !isError && (traces?.data?.data?.length ?? 0) === 0;
  131. const data = normalizeTraces(!isLoading && !isError ? traces?.data?.data : undefined);
  132. return (
  133. <LayoutMain fullWidth>
  134. <PageFilterBar condensed>
  135. <EnvironmentPageFilter />
  136. <DatePageFilter defaultPeriod="2h" />
  137. </PageFilterBar>
  138. {hasMetric && (
  139. <StyledAlert
  140. type="info"
  141. showIcon
  142. trailingItems={<StyledCloseButton onClick={removeMetric} />}
  143. >
  144. {tct('The metric query [metricQuery] is filtering the results below.', {
  145. metricQuery: (
  146. <strong>
  147. {getFormattedMQL({mri: mri as MRI, op: metricsOp, query: metricsQuery})}
  148. </strong>
  149. ),
  150. })}
  151. </StyledAlert>
  152. )}
  153. {isError && typeof traces.error?.responseJSON?.detail === 'string' ? (
  154. <StyledAlert type="error" showIcon>
  155. {traces.error?.responseJSON?.detail}
  156. </StyledAlert>
  157. ) : null}
  158. <TracesSearchBar
  159. queries={queries}
  160. handleSearch={handleSearch}
  161. handleClearSearch={handleClearSearch}
  162. />
  163. <StyledPanel>
  164. <TracePanelContent>
  165. <StyledPanelHeader align="left" lightText>
  166. {t('Trace ID')}
  167. </StyledPanelHeader>
  168. <StyledPanelHeader align="left" lightText>
  169. {t('Trace Root')}
  170. </StyledPanelHeader>
  171. <StyledPanelHeader align="right" lightText>
  172. {t('Matching Spans')}
  173. </StyledPanelHeader>
  174. <StyledPanelHeader align="left" lightText>
  175. {t('Timeline')}
  176. </StyledPanelHeader>
  177. <StyledPanelHeader align="right" lightText>
  178. {t('Duration')}
  179. </StyledPanelHeader>
  180. <StyledPanelHeader align="right" lightText>
  181. {t('Timestamp')}
  182. </StyledPanelHeader>
  183. <StyledPanelHeader align="right" lightText>
  184. {t('Issues')}
  185. </StyledPanelHeader>
  186. {isLoading && (
  187. <StyledPanelItem span={7} overflow>
  188. <LoadingIndicator />
  189. </StyledPanelItem>
  190. )}
  191. {isError && ( // TODO: need an error state
  192. <StyledPanelItem span={7} overflow>
  193. <EmptyStateWarning withIcon />
  194. </StyledPanelItem>
  195. )}
  196. {isEmpty && (
  197. <StyledPanelItem span={7} overflow>
  198. <EmptyStateWarning withIcon>
  199. <div>{t('No traces available')}</div>
  200. </EmptyStateWarning>
  201. </StyledPanelItem>
  202. )}
  203. {data?.map((trace, i) => (
  204. <TraceRow key={trace.trace} trace={trace} defaultExpanded={i === 0} />
  205. ))}
  206. </TracePanelContent>
  207. </StyledPanel>
  208. </LayoutMain>
  209. );
  210. }
  211. function TraceRow({
  212. defaultExpanded,
  213. trace,
  214. }: {
  215. defaultExpanded;
  216. trace: TraceResult<Field>;
  217. }) {
  218. const [expanded, setExpanded] = useState<boolean>(defaultExpanded);
  219. const [highlightedSliceName, _setHighlightedSliceName] = useState('');
  220. const setHighlightedSliceName = useMemo(
  221. () =>
  222. debounce(sliceName => _setHighlightedSliceName(sliceName), 100, {
  223. leading: true,
  224. }),
  225. [_setHighlightedSliceName]
  226. );
  227. const onClickExpand = useCallback(() => setExpanded(e => !e), [setExpanded]);
  228. return (
  229. <Fragment>
  230. <StyledPanelItem align="center" center onClick={onClickExpand}>
  231. <Button
  232. icon={<IconChevron size="xs" direction={expanded ? 'down' : 'right'} />}
  233. aria-label={t('Toggle trace details')}
  234. aria-expanded={expanded}
  235. size="zero"
  236. borderless
  237. />
  238. <TraceIdRenderer traceId={trace.trace} timestamp={trace.spans[0].timestamp} />
  239. </StyledPanelItem>
  240. <StyledPanelItem align="left" overflow>
  241. <Description>
  242. {trace.project ? (
  243. <ProjectRenderer projectSlug={trace.project} hideName />
  244. ) : null}
  245. {trace.name ? (
  246. <WrappingText>{trace.name}</WrappingText>
  247. ) : (
  248. <EmptyValueContainer>{t('Missing Trace Root')}</EmptyValueContainer>
  249. )}
  250. </Description>
  251. </StyledPanelItem>
  252. <StyledPanelItem align="right">
  253. {tct('[numerator][space]of[space][denominator]', {
  254. numerator: <Count value={trace.matchingSpans} />,
  255. denominator: <Count value={trace.numSpans} />,
  256. space: <Fragment>&nbsp;</Fragment>,
  257. })}
  258. </StyledPanelItem>
  259. <BreakdownPanelItem
  260. align="right"
  261. highlightedSliceName={highlightedSliceName}
  262. onMouseLeave={() => setHighlightedSliceName('')}
  263. >
  264. <TraceBreakdownRenderer
  265. trace={trace}
  266. setHighlightedSliceName={setHighlightedSliceName}
  267. />
  268. </BreakdownPanelItem>
  269. <StyledPanelItem align="right">
  270. <PerformanceDuration milliseconds={trace.duration} abbreviation />
  271. </StyledPanelItem>
  272. <StyledPanelItem align="right">
  273. <SpanTimeRenderer timestamp={trace.end} tooltipShowSeconds />
  274. </StyledPanelItem>
  275. <StyledPanelItem align="right">
  276. <TraceIssuesRenderer trace={trace} />
  277. </StyledPanelItem>
  278. {expanded && (
  279. <SpanTable
  280. spans={trace.spans}
  281. trace={trace}
  282. setHighlightedSliceName={setHighlightedSliceName}
  283. />
  284. )}
  285. </Fragment>
  286. );
  287. }
  288. function SpanTable({
  289. spans,
  290. trace,
  291. setHighlightedSliceName,
  292. }: {
  293. setHighlightedSliceName: (sliceName: string) => void;
  294. spans: SpanResult<Field>[];
  295. trace: TraceResult<Field>;
  296. }) {
  297. return (
  298. <SpanTablePanelItem span={7} overflow>
  299. <StyledPanel>
  300. <SpanPanelContent>
  301. <StyledPanelHeader align="left" lightText>
  302. {t('Span ID')}
  303. </StyledPanelHeader>
  304. <StyledPanelHeader align="left" lightText>
  305. {t('Span Description')}
  306. </StyledPanelHeader>
  307. <StyledPanelHeader align="right" lightText />
  308. <StyledPanelHeader align="right" lightText>
  309. {t('Span Duration')}
  310. </StyledPanelHeader>
  311. <StyledPanelHeader align="right" lightText>
  312. {t('Timestamp')}
  313. </StyledPanelHeader>
  314. {spans.map(span => (
  315. <SpanRow
  316. key={span.id}
  317. span={span}
  318. trace={trace}
  319. setHighlightedSliceName={setHighlightedSliceName}
  320. />
  321. ))}
  322. {spans.length < trace.matchingSpans && (
  323. <MoreMatchingSpans span={5}>
  324. {tct('[more][space]more matching spans can be found in the trace.', {
  325. more: <Count value={trace.matchingSpans - spans.length} />,
  326. space: <Fragment>&nbsp;</Fragment>,
  327. })}
  328. </MoreMatchingSpans>
  329. )}
  330. </SpanPanelContent>
  331. </StyledPanel>
  332. </SpanTablePanelItem>
  333. );
  334. }
  335. function SpanRow({
  336. span,
  337. trace,
  338. setHighlightedSliceName,
  339. }: {
  340. setHighlightedSliceName: (sliceName: string) => void;
  341. span: SpanResult<Field>;
  342. trace: TraceResult<Field>;
  343. }) {
  344. const theme = useTheme();
  345. return (
  346. <Fragment>
  347. <StyledSpanPanelItem align="right">
  348. <SpanIdRenderer
  349. projectSlug={span.project}
  350. transactionId={span['transaction.id']}
  351. spanId={span.id}
  352. traceId={trace.trace}
  353. timestamp={span.timestamp}
  354. />
  355. </StyledSpanPanelItem>
  356. <StyledSpanPanelItem align="left" overflow>
  357. <SpanDescriptionRenderer span={span} />
  358. </StyledSpanPanelItem>
  359. <StyledSpanPanelItem align="right" onMouseLeave={() => setHighlightedSliceName('')}>
  360. <TraceBreakdownContainer>
  361. <SpanBreakdownSliceRenderer
  362. sliceName={span.project}
  363. sliceSecondaryName={getSecondaryNameFromSpan(span)}
  364. sliceStart={Math.ceil(span['precise.start_ts'] * 1000)}
  365. sliceEnd={Math.floor(span['precise.finish_ts'] * 1000)}
  366. trace={trace}
  367. theme={theme}
  368. onMouseEnter={() =>
  369. setHighlightedSliceName(
  370. getStylingSliceName(span.project, getSecondaryNameFromSpan(span)) ?? ''
  371. )
  372. }
  373. />
  374. </TraceBreakdownContainer>
  375. </StyledSpanPanelItem>
  376. <StyledSpanPanelItem align="right">
  377. <PerformanceDuration milliseconds={span['span.duration']} abbreviation />
  378. </StyledSpanPanelItem>
  379. <StyledSpanPanelItem align="right">
  380. <SpanTimeRenderer
  381. timestamp={span['precise.finish_ts'] * 1000}
  382. tooltipShowSeconds
  383. />
  384. </StyledSpanPanelItem>
  385. </Fragment>
  386. );
  387. }
  388. export type SpanResult<F extends string> = Record<F, any>;
  389. export interface TraceResult<F extends string> {
  390. breakdowns: TraceBreakdownResult[];
  391. duration: number;
  392. end: number;
  393. matchingSpans: number;
  394. name: string | null;
  395. numErrors: number;
  396. numOccurrences: number;
  397. numSpans: number;
  398. project: string | null;
  399. spans: SpanResult<F>[];
  400. start: number;
  401. trace: string;
  402. }
  403. interface TraceBreakdownBase {
  404. duration: number; // Contains the accurate duration for display. Start and end may be quantized.
  405. end: number;
  406. opCategory: string | null;
  407. sdkName: string | null;
  408. start: number;
  409. }
  410. type TraceBreakdownProject = TraceBreakdownBase & {
  411. kind: 'project';
  412. project: string;
  413. };
  414. type TraceBreakdownMissing = TraceBreakdownBase & {
  415. kind: 'missing';
  416. project: null;
  417. };
  418. export type TraceBreakdownResult = TraceBreakdownProject | TraceBreakdownMissing;
  419. interface TraceResults<F extends string> {
  420. data: TraceResult<F>[];
  421. meta: any;
  422. }
  423. interface UseTracesOptions<F extends string> {
  424. fields: F[];
  425. datetime?: PageFilters['datetime'];
  426. enabled?: boolean;
  427. limit?: number;
  428. metricsMax?: string;
  429. metricsMin?: string;
  430. metricsOp?: string;
  431. metricsQuery?: string;
  432. mri?: string;
  433. query?: string | string[];
  434. sort?: string[];
  435. suggestedQuery?: string;
  436. }
  437. function useTraces<F extends string>({
  438. fields,
  439. datetime,
  440. enabled,
  441. limit,
  442. mri,
  443. metricsMax,
  444. metricsMin,
  445. metricsOp,
  446. metricsQuery,
  447. query,
  448. suggestedQuery,
  449. sort,
  450. }: UseTracesOptions<F>) {
  451. const organization = useOrganization();
  452. const {selection} = usePageFilters();
  453. const path = `/organizations/${organization.slug}/traces/`;
  454. const endpointOptions = {
  455. query: {
  456. project: selection.projects,
  457. environment: selection.environments,
  458. ...(datetime ?? normalizeDateTimeParams(selection.datetime)),
  459. field: fields,
  460. query,
  461. suggestedQuery,
  462. sort,
  463. per_page: limit,
  464. breakdownSlices: 40,
  465. minBreakdownPercentage: 1 / 40,
  466. maxSpansPerTrace: 10,
  467. mri,
  468. metricsMax,
  469. metricsMin,
  470. metricsOp,
  471. metricsQuery,
  472. },
  473. };
  474. return useApiQuery<TraceResults<F>>([path, endpointOptions], {
  475. staleTime: 0,
  476. refetchOnWindowFocus: false,
  477. retry: false,
  478. enabled,
  479. });
  480. }
  481. const LayoutMain = styled(Layout.Main)`
  482. display: flex;
  483. flex-direction: column;
  484. gap: ${space(2)};
  485. `;
  486. const StyledPanel = styled(Panel)`
  487. margin-bottom: 0px;
  488. `;
  489. const TracePanelContent = styled('div')`
  490. width: 100%;
  491. display: grid;
  492. grid-template-columns: repeat(1, min-content) auto repeat(2, min-content) 85px 85px 66px;
  493. `;
  494. const SpanPanelContent = styled('div')`
  495. width: 100%;
  496. display: grid;
  497. grid-template-columns: repeat(1, min-content) auto repeat(1, min-content) 133px 85px;
  498. `;
  499. const StyledPanelHeader = styled(PanelHeader)<{align: 'left' | 'right'}>`
  500. white-space: nowrap;
  501. justify-content: ${p => (p.align === 'left' ? 'flex-start' : 'flex-end')};
  502. `;
  503. const Description = styled('div')`
  504. ${p => p.theme.overflowEllipsis};
  505. display: flex;
  506. flex-direction: row;
  507. align-items: center;
  508. gap: ${space(1)};
  509. `;
  510. const StyledPanelItem = styled(PanelItem)<{
  511. align?: 'left' | 'center' | 'right';
  512. overflow?: boolean;
  513. span?: number;
  514. }>`
  515. align-items: center;
  516. padding: ${space(1)} ${space(2)};
  517. ${p => (p.align === 'left' ? 'justify-content: flex-start;' : null)}
  518. ${p => (p.align === 'right' ? 'justify-content: flex-end;' : null)}
  519. ${p => (p.overflow ? p.theme.overflowEllipsis : null)};
  520. ${p =>
  521. p.align === 'center'
  522. ? `
  523. justify-content: space-around;`
  524. : p.align === 'left' || p.align === 'right'
  525. ? `text-align: ${p.align};`
  526. : undefined}
  527. ${p => p.span && `grid-column: auto / span ${p.span};`}
  528. white-space: nowrap;
  529. `;
  530. const MoreMatchingSpans = styled(StyledPanelItem)`
  531. color: ${p => p.theme.gray300};
  532. `;
  533. const WrappingText = styled('div')`
  534. width: 100%;
  535. ${p => p.theme.overflowEllipsis};
  536. `;
  537. const StyledSpanPanelItem = styled(StyledPanelItem)`
  538. &:nth-child(10n + 1),
  539. &:nth-child(10n + 2),
  540. &:nth-child(10n + 3),
  541. &:nth-child(10n + 4),
  542. &:nth-child(10n + 5) {
  543. background-color: ${p => p.theme.backgroundSecondary};
  544. }
  545. `;
  546. const SpanTablePanelItem = styled(StyledPanelItem)`
  547. background-color: ${p => p.theme.gray100};
  548. `;
  549. const BreakdownPanelItem = styled(StyledPanelItem)<{highlightedSliceName: string}>`
  550. ${p =>
  551. p.highlightedSliceName
  552. ? `--highlightedSlice-${p.highlightedSliceName}-opacity: 1.0;
  553. --highlightedSlice-${p.highlightedSliceName}-saturate: saturate(1.0) contrast(1.0);
  554. --highlightedSlice-${p.highlightedSliceName}-transform: translateY(0px);
  555. `
  556. : null}
  557. ${p =>
  558. p.highlightedSliceName
  559. ? `
  560. --defaultSlice-opacity: 1.0;
  561. --defaultSlice-saturate: saturate(0.7) contrast(0.9) brightness(1.2);
  562. --defaultSlice-transform: translateY(0px);
  563. `
  564. : `
  565. --defaultSlice-opacity: 1.0;
  566. --defaultSlice-saturate: saturate(1.0) contrast(1.0);
  567. --defaultSlice-transform: translateY(0px);
  568. `}
  569. `;
  570. const EmptyValueContainer = styled('span')`
  571. color: ${p => p.theme.gray300};
  572. `;
  573. const StyledAlert = styled(Alert)`
  574. margin-bottom: 0;
  575. `;
  576. const StyledCloseButton = styled(IconClose)`
  577. cursor: pointer;
  578. `;