index.tsx 14 KB

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