content.tsx 17 KB

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