index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import type {Dispatch, SetStateAction} from 'react';
  2. import {Fragment, useCallback, useEffect, useMemo} from 'react';
  3. import styled from '@emotion/styled';
  4. import isEqual from 'lodash/isEqual';
  5. import {CompactSelect} from 'sentry/components/compactSelect';
  6. import Count from 'sentry/components/count';
  7. import {Tooltip} from 'sentry/components/tooltip';
  8. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  9. import {IconClock, IconGraph} from 'sentry/icons';
  10. import {t, tct} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import type {Confidence, NewQuery} from 'sentry/types/organization';
  13. import {defined} from 'sentry/utils';
  14. import {dedupeArray} from 'sentry/utils/dedupeArray';
  15. import EventView from 'sentry/utils/discover/eventView';
  16. import {
  17. aggregateOutputType,
  18. parseFunction,
  19. prettifyParsedFunction,
  20. } from 'sentry/utils/discover/fields';
  21. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  22. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  23. import usePageFilters from 'sentry/utils/usePageFilters';
  24. import usePrevious from 'sentry/utils/usePrevious';
  25. import {formatVersion} from 'sentry/utils/versions/formatVersion';
  26. import ChartContextMenu from 'sentry/views/explore/components/chartContextMenu';
  27. import {
  28. useExploreDataset,
  29. useExploreGroupBys,
  30. useExploreMode,
  31. useExploreSortBys,
  32. useExploreVisualizes,
  33. useSetExploreVisualizes,
  34. } from 'sentry/views/explore/contexts/pageParamsContext';
  35. import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode';
  36. import {formatSort} from 'sentry/views/explore/contexts/pageParamsContext/sortBys';
  37. import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval';
  38. import Chart, {
  39. ChartType,
  40. useSynchronizeCharts,
  41. } from 'sentry/views/insights/common/components/chart';
  42. import ChartPanel from 'sentry/views/insights/common/components/chartPanel';
  43. import {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries';
  44. import {useSpansQuery} from 'sentry/views/insights/common/queries/useSpansQuery';
  45. import {CHART_HEIGHT} from 'sentry/views/insights/database/settings';
  46. import {TOP_EVENTS_LIMIT, useTopEvents} from '../hooks/useTopEvents';
  47. interface ExploreChartsProps {
  48. query: string;
  49. setConfidence: Dispatch<SetStateAction<Confidence>>;
  50. setError: Dispatch<SetStateAction<string>>;
  51. }
  52. const exploreChartTypeOptions = [
  53. {
  54. value: ChartType.LINE,
  55. label: t('Line'),
  56. },
  57. {
  58. value: ChartType.AREA,
  59. label: t('Area'),
  60. },
  61. {
  62. value: ChartType.BAR,
  63. label: t('Bar'),
  64. },
  65. ];
  66. export const EXPLORE_CHART_GROUP = 'explore-charts_group';
  67. export function ExploreCharts({query, setConfidence, setError}: ExploreChartsProps) {
  68. const dataset = useExploreDataset();
  69. const visualizes = useExploreVisualizes();
  70. const setVisualizes = useSetExploreVisualizes();
  71. const [interval, setInterval, intervalOptions] = useChartInterval();
  72. const groupBys = useExploreGroupBys();
  73. const mode = useExploreMode();
  74. const topEvents = useTopEvents();
  75. const extrapolationMetaResults = useExtrapolationMeta({
  76. dataset,
  77. query,
  78. });
  79. const fields: string[] = useMemo(() => {
  80. if (mode === Mode.SAMPLES) {
  81. return [];
  82. }
  83. return [...groupBys, ...visualizes.flatMap(visualize => visualize.yAxes)].filter(
  84. Boolean
  85. );
  86. }, [mode, groupBys, visualizes]);
  87. const sortBys = useExploreSortBys();
  88. const orderby: string | string[] | undefined = useMemo(() => {
  89. if (!sortBys.length) {
  90. return undefined;
  91. }
  92. return sortBys.map(formatSort);
  93. }, [sortBys]);
  94. const yAxes = useMemo(() => {
  95. const deduped = dedupeArray(visualizes.flatMap(visualize => visualize.yAxes));
  96. deduped.sort();
  97. return deduped;
  98. }, [visualizes]);
  99. const options = useMemo(() => {
  100. const search = new MutableSearch(query);
  101. // Filtering out all spans with op like 'ui.interaction*' which aren't
  102. // embedded under transactions. The trace view does not support rendering
  103. // such spans yet.
  104. search.addFilterValues('!transaction.span_id', ['00']);
  105. return {
  106. search,
  107. yAxis: yAxes,
  108. interval,
  109. fields,
  110. orderby,
  111. topEvents,
  112. };
  113. }, [query, yAxes, interval, fields, orderby, topEvents]);
  114. const previousQuery = usePrevious(query);
  115. const previousOptions = usePrevious(options);
  116. const canUsePreviousResults = useMemo(() => {
  117. if (!isEqual(query, previousQuery)) {
  118. return false;
  119. }
  120. if (!isEqual(options.interval, previousOptions.interval)) {
  121. return false;
  122. }
  123. if (!isEqual(options.fields, previousOptions.fields)) {
  124. return false;
  125. }
  126. if (!isEqual(options.orderby, previousOptions.orderby)) {
  127. return false;
  128. }
  129. if (!isEqual(options.topEvents, previousOptions.topEvents)) {
  130. return false;
  131. }
  132. return true;
  133. }, [query, previousQuery, options, previousOptions]);
  134. const timeSeriesResult = useSortedTimeSeries(options, 'api.explorer.stats', dataset);
  135. const previousTimeSeriesResult = usePrevious(timeSeriesResult);
  136. const resultConfidence = useMemo(() => {
  137. if (dataset !== DiscoverDatasets.SPANS_EAP_RPC) {
  138. return null;
  139. }
  140. const {lowConfidence, highConfidence, nullConfidence} = Object.values(
  141. timeSeriesResult.data
  142. ).reduce(
  143. (acc, series) => {
  144. for (const s of series) {
  145. if (s.confidence === 'low') {
  146. acc.lowConfidence += 1;
  147. } else if (s.confidence === 'high') {
  148. acc.highConfidence += 1;
  149. } else {
  150. acc.nullConfidence += 1;
  151. }
  152. }
  153. return acc;
  154. },
  155. {lowConfidence: 0, highConfidence: 0, nullConfidence: 0}
  156. );
  157. if (lowConfidence <= 0 && highConfidence <= 0 && nullConfidence >= 0) {
  158. return null;
  159. }
  160. if (lowConfidence / (lowConfidence + highConfidence) > 0.5) {
  161. return 'low';
  162. }
  163. return 'high';
  164. }, [dataset, timeSeriesResult.data]);
  165. useEffect(() => {
  166. // only update the confidence once the result has loaded
  167. if (!timeSeriesResult.isPending) {
  168. setConfidence(resultConfidence);
  169. }
  170. }, [setConfidence, resultConfidence, timeSeriesResult.isPending]);
  171. useEffect(() => {
  172. setError(timeSeriesResult.error?.message ?? '');
  173. }, [setError, timeSeriesResult.error?.message]);
  174. const getSeries = useCallback(
  175. (dedupedYAxes: string[], formattedYAxes: (string | undefined)[]) => {
  176. const shouldUsePreviousResults =
  177. timeSeriesResult.isPending &&
  178. canUsePreviousResults &&
  179. dedupedYAxes.every(yAxis => previousTimeSeriesResult.data.hasOwnProperty(yAxis));
  180. const data = dedupedYAxes.flatMap((yAxis, i) => {
  181. const series = shouldUsePreviousResults
  182. ? previousTimeSeriesResult.data[yAxis]
  183. : timeSeriesResult.data[yAxis];
  184. return (series ?? []).map(s => {
  185. // We replace the series name with the formatted series name here
  186. // when possible as it's cleaner to read.
  187. //
  188. // We can't do this in top N mode as the series name uses the row
  189. // values instead of the aggregate function.
  190. if (s.seriesName === yAxis) {
  191. return {
  192. ...s,
  193. seriesName: formattedYAxes[i] ?? yAxis,
  194. };
  195. }
  196. return s;
  197. });
  198. });
  199. return {
  200. data,
  201. error: shouldUsePreviousResults
  202. ? previousTimeSeriesResult.error
  203. : timeSeriesResult.error,
  204. loading: shouldUsePreviousResults
  205. ? previousTimeSeriesResult.isPending
  206. : timeSeriesResult.isPending,
  207. };
  208. },
  209. [canUsePreviousResults, timeSeriesResult, previousTimeSeriesResult]
  210. );
  211. const handleChartTypeChange = useCallback(
  212. (chartType: ChartType, index: number) => {
  213. const newVisualizes = visualizes.slice();
  214. newVisualizes[index] = {...newVisualizes[index]!, chartType};
  215. setVisualizes(newVisualizes);
  216. },
  217. [visualizes, setVisualizes]
  218. );
  219. useSynchronizeCharts(
  220. visualizes.length,
  221. !timeSeriesResult.isPending,
  222. EXPLORE_CHART_GROUP
  223. );
  224. const shouldRenderLabel = visualizes.length > 1;
  225. return (
  226. <Fragment>
  227. {visualizes.map((visualize, index) => {
  228. const dedupedYAxes = dedupeArray(visualize.yAxes);
  229. const formattedYAxes = dedupedYAxes.map(yaxis => {
  230. const func = parseFunction(yaxis);
  231. return func ? prettifyParsedFunction(func) : undefined;
  232. });
  233. const {chartType, label, yAxes: visualizeYAxes} = visualize;
  234. const chartIcon =
  235. chartType === ChartType.LINE
  236. ? 'line'
  237. : chartType === ChartType.AREA
  238. ? 'area'
  239. : 'bar';
  240. const {data, error, loading} = getSeries(dedupedYAxes, formattedYAxes);
  241. const outputTypes = new Set(
  242. formattedYAxes.filter(Boolean).map(aggregateOutputType)
  243. );
  244. return (
  245. <ChartContainer key={index}>
  246. <ChartPanel>
  247. <ChartHeader>
  248. {shouldRenderLabel && <ChartLabel>{label}</ChartLabel>}
  249. <ChartTitle>{formattedYAxes.filter(Boolean).join(', ')}</ChartTitle>
  250. <Tooltip
  251. title={t('Type of chart displayed in this visualization (ex. line)')}
  252. >
  253. <CompactSelect
  254. triggerProps={{
  255. icon: <IconGraph type={chartIcon} />,
  256. borderless: true,
  257. showChevron: false,
  258. size: 'sm',
  259. }}
  260. value={chartType}
  261. menuTitle="Type"
  262. options={exploreChartTypeOptions}
  263. onChange={option => handleChartTypeChange(option.value, index)}
  264. />
  265. </Tooltip>
  266. <Tooltip
  267. title={t('Time interval displayed in this visualization (ex. 5m)')}
  268. >
  269. <CompactSelect
  270. value={interval}
  271. onChange={({value}) => setInterval(value)}
  272. triggerProps={{
  273. icon: <IconClock />,
  274. borderless: true,
  275. showChevron: false,
  276. size: 'sm',
  277. }}
  278. menuTitle="Interval"
  279. options={intervalOptions}
  280. />
  281. </Tooltip>
  282. <ChartContextMenu
  283. visualizeYAxes={visualizeYAxes}
  284. query={query}
  285. interval={interval}
  286. visualizeIndex={index}
  287. />
  288. </ChartHeader>
  289. <Chart
  290. height={CHART_HEIGHT}
  291. grid={{
  292. left: '0',
  293. right: '0',
  294. top: '32px', // make room to fit the legend above the chart
  295. bottom: '0',
  296. }}
  297. legendFormatter={value => formatVersion(value)}
  298. legendOptions={{
  299. itemGap: 24,
  300. top: '4px',
  301. }}
  302. data={data}
  303. error={error}
  304. loading={loading}
  305. chartGroup={EXPLORE_CHART_GROUP}
  306. // TODO Abdullah: Make chart colors dynamic, with changing topN events count and overlay count.
  307. chartColors={CHART_PALETTE[TOP_EVENTS_LIMIT - 1]}
  308. type={chartType}
  309. aggregateOutputFormat={
  310. outputTypes.size === 1 ? outputTypes.keys().next().value : undefined
  311. }
  312. showLegend
  313. />
  314. {dataset === DiscoverDatasets.SPANS_EAP_RPC && (
  315. <ChartFooter>
  316. {!defined(extrapolationMetaResults.data?.[0]?.['count_sample()'])
  317. ? t('* Extrapolated from \u2026')
  318. : resultConfidence === 'low'
  319. ? tct(
  320. '* Extrapolated from [sampleCount] samples ([insufficientSamples])',
  321. {
  322. sampleCount: (
  323. <Count
  324. value={
  325. extrapolationMetaResults.data?.[0]?.['count_sample()']
  326. }
  327. />
  328. ),
  329. insufficientSamples: (
  330. <Tooltip
  331. title={t(
  332. 'Shortening the date range, increasing the time interval or removing extra filters may improve accuracy.'
  333. )}
  334. >
  335. <InsufficientSamples>
  336. {t('insufficient for accuracy')}
  337. </InsufficientSamples>
  338. </Tooltip>
  339. ),
  340. }
  341. )
  342. : tct('* Extrapolated from [sampleCount] samples', {
  343. sampleCount: (
  344. <Count
  345. value={
  346. extrapolationMetaResults.data?.[0]?.['count_sample()']
  347. }
  348. />
  349. ),
  350. })}
  351. </ChartFooter>
  352. )}
  353. </ChartPanel>
  354. </ChartContainer>
  355. );
  356. })}
  357. </Fragment>
  358. );
  359. }
  360. function useExtrapolationMeta({
  361. dataset,
  362. query,
  363. }: {
  364. dataset: DiscoverDatasets;
  365. query: string;
  366. }) {
  367. const {selection} = usePageFilters();
  368. const extrapolationMetaEventView = useMemo(() => {
  369. const search = new MutableSearch(query);
  370. // Filtering out all spans with op like 'ui.interaction*' which aren't
  371. // embedded under transactions. The trace view does not support rendering
  372. // such spans yet.
  373. search.addFilterValues('!transaction.span_id', ['00']);
  374. const discoverQuery: NewQuery = {
  375. id: undefined,
  376. name: 'Explore - Extrapolation Meta',
  377. fields: ['count_sample()', 'min(sampling_rate)'],
  378. query: search.formatString(),
  379. version: 2,
  380. dataset,
  381. };
  382. return EventView.fromNewQueryWithPageFilters(discoverQuery, selection);
  383. }, [dataset, query, selection]);
  384. return useSpansQuery({
  385. eventView: extrapolationMetaEventView,
  386. initialData: [],
  387. referrer: 'api.explore.spans-extrapolation-meta',
  388. enabled: dataset === DiscoverDatasets.SPANS_EAP_RPC,
  389. });
  390. }
  391. const ChartContainer = styled('div')`
  392. display: grid;
  393. gap: 0;
  394. grid-template-columns: 1fr;
  395. margin-bottom: ${space(2)};
  396. `;
  397. const ChartHeader = styled('div')`
  398. display: flex;
  399. justify-content: space-between;
  400. `;
  401. const ChartTitle = styled('div')`
  402. ${p => p.theme.text.cardTitle}
  403. line-height: 32px;
  404. flex: 1;
  405. `;
  406. const ChartLabel = styled('div')`
  407. background-color: ${p => p.theme.purple100};
  408. border-radius: ${p => p.theme.borderRadius};
  409. text-align: center;
  410. min-width: 32px;
  411. color: ${p => p.theme.purple400};
  412. white-space: nowrap;
  413. font-weight: ${p => p.theme.fontWeightBold};
  414. align-content: center;
  415. margin-right: ${space(1)};
  416. `;
  417. const ChartFooter = styled('div')`
  418. color: ${p => p.theme.gray300};
  419. font-size: ${p => p.theme.fontSizeSmall};
  420. display: inline-block;
  421. margin-top: ${space(1.5)};
  422. margin-bottom: 0;
  423. `;
  424. const InsufficientSamples = styled('span')`
  425. text-decoration: underline dotted ${p => p.theme.gray300};
  426. `;