content.tsx 18 KB

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