metricsExplorer.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import {Theme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import moment from 'moment';
  5. import Alert from 'sentry/components/alert';
  6. import {AreaChart} from 'sentry/components/charts/areaChart';
  7. import {BarChart} from 'sentry/components/charts/barChart';
  8. import ChartZoom from 'sentry/components/charts/chartZoom';
  9. import Legend from 'sentry/components/charts/components/legend';
  10. import {LineChart} from 'sentry/components/charts/lineChart';
  11. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  12. import {CompactSelect} from 'sentry/components/compactSelect';
  13. import EmptyMessage from 'sentry/components/emptyMessage';
  14. import SearchBar from 'sentry/components/events/searchBar';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  17. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  18. import Panel from 'sentry/components/panels/panel';
  19. import PanelBody from 'sentry/components/panels/panelBody';
  20. import Tag from 'sentry/components/tag';
  21. import {IconSearch} from 'sentry/icons';
  22. import {t} from 'sentry/locale';
  23. import {space} from 'sentry/styles/space';
  24. import {MetricsTag, PageFilters, TagCollection} from 'sentry/types';
  25. import {
  26. defaultMetricDisplayType,
  27. formatMetricsUsingUnitAndOp,
  28. getNameFromMRI,
  29. getReadableMetricType,
  30. getUnitFromMRI,
  31. getUseCaseFromMri,
  32. isAllowedOp,
  33. MetricDisplayType,
  34. MetricsData,
  35. MetricsDataProps,
  36. MetricsQuery,
  37. updateQuery,
  38. useMetricsData,
  39. useMetricsMeta,
  40. useMetricsTags,
  41. } from 'sentry/utils/metrics';
  42. import {decodeList} from 'sentry/utils/queryString';
  43. import theme from 'sentry/utils/theme';
  44. import useApi from 'sentry/utils/useApi';
  45. import useKeyPress from 'sentry/utils/useKeyPress';
  46. import useOrganization from 'sentry/utils/useOrganization';
  47. import usePageFilters from 'sentry/utils/usePageFilters';
  48. import useRouter from 'sentry/utils/useRouter';
  49. import {SummaryTable} from 'sentry/views/ddm/summaryTable';
  50. function MetricsExplorer() {
  51. const {selection} = usePageFilters();
  52. const router = useRouter();
  53. const metricsQuery: MetricsQuery = {
  54. mri: router.location.query.mri,
  55. op: router.location.query.op,
  56. query: router.location.query.query,
  57. groupBy: decodeList(router.location.query.groupBy),
  58. };
  59. return (
  60. <MetricsExplorerPanel>
  61. <PanelBody>
  62. <QueryBuilder metricsQuery={metricsQuery} />
  63. <MetricsExplorerDisplayOuter
  64. displayType={router.location.query.display ?? defaultMetricDisplayType}
  65. datetime={selection.datetime}
  66. projects={selection.projects}
  67. environments={selection.environments}
  68. {...metricsQuery}
  69. />
  70. </PanelBody>
  71. </MetricsExplorerPanel>
  72. );
  73. }
  74. type QueryBuilderProps = {
  75. metricsQuery: MetricsQuery;
  76. };
  77. function QueryBuilder({metricsQuery}: QueryBuilderProps) {
  78. const router = useRouter();
  79. const {selection} = usePageFilters();
  80. const meta = useMetricsMeta(selection.projects);
  81. const mriModeKeyPressed = useKeyPress('`', undefined, true);
  82. const [mriMode, setMriMode] = useState(false); // power user mode that shows raw MRI instead of metrics names
  83. useEffect(() => {
  84. if (mriModeKeyPressed) {
  85. setMriMode(!mriMode);
  86. }
  87. // eslint-disable-next-line react-hooks/exhaustive-deps
  88. }, [mriModeKeyPressed]);
  89. const {data: tags = []} = useMetricsTags(metricsQuery.mri, selection.projects);
  90. if (!meta) {
  91. return null;
  92. }
  93. return (
  94. <QueryBuilderWrapper>
  95. <QueryBuilderRow>
  96. <PageFilterBar condensed>
  97. <CompactSelect
  98. searchable
  99. triggerProps={{prefix: t('Metric'), size: 'sm'}}
  100. options={Object.values(meta)
  101. .filter(metric =>
  102. mriMode
  103. ? true
  104. : metric.mri.includes(':custom/') || metric.mri === metricsQuery.mri
  105. )
  106. .map(metric => ({
  107. label: mriMode ? metric.mri : metric.name,
  108. value: metric.mri,
  109. trailingItems: mriMode ? undefined : (
  110. <Fragment>
  111. <Tag tooltipText={t('Type')}>
  112. {getReadableMetricType(metric.type)}
  113. </Tag>
  114. <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
  115. </Fragment>
  116. ),
  117. }))}
  118. value={metricsQuery.mri}
  119. onChange={option => {
  120. const availableOps = meta[option.value]?.operations.filter(isAllowedOp);
  121. const selectedOp = availableOps.includes(metricsQuery.op ?? '')
  122. ? metricsQuery.op
  123. : availableOps[0];
  124. updateQuery(router, {
  125. mri: option.value,
  126. op: selectedOp,
  127. groupBy: undefined,
  128. });
  129. }}
  130. />
  131. <CompactSelect
  132. triggerProps={{prefix: t('Operation'), size: 'sm'}}
  133. options={
  134. meta[metricsQuery.mri]?.operations.filter(isAllowedOp).map(op => ({
  135. label: op,
  136. value: op,
  137. })) ?? []
  138. }
  139. disabled={!metricsQuery.mri}
  140. value={metricsQuery.op}
  141. onChange={option =>
  142. updateQuery(router, {
  143. op: option.value,
  144. })
  145. }
  146. />
  147. <CompactSelect
  148. multiple
  149. triggerProps={{prefix: t('Group by'), size: 'sm'}}
  150. options={tags.map(tag => ({
  151. label: tag.key,
  152. value: tag.key,
  153. }))}
  154. disabled={!metricsQuery.mri}
  155. value={metricsQuery.groupBy}
  156. onChange={options =>
  157. updateQuery(router, {
  158. groupBy: options.map(o => o.value),
  159. })
  160. }
  161. />
  162. </PageFilterBar>
  163. </QueryBuilderRow>
  164. <QueryBuilderRow>
  165. <MetricSearchBar
  166. tags={tags}
  167. mri={metricsQuery.mri}
  168. disabled={!metricsQuery.mri}
  169. onChange={query => updateQuery(router, {query})}
  170. query={metricsQuery.query}
  171. />
  172. </QueryBuilderRow>
  173. </QueryBuilderWrapper>
  174. );
  175. }
  176. type MetricSearchBarProps = {
  177. mri: string;
  178. onChange: (value: string) => void;
  179. tags: MetricsTag[];
  180. disabled?: boolean;
  181. query?: string;
  182. };
  183. function MetricSearchBar({tags, mri, disabled, onChange, query}: MetricSearchBarProps) {
  184. const org = useOrganization();
  185. const api = useApi();
  186. const {selection} = usePageFilters();
  187. const supportedTags: TagCollection = useMemo(
  188. () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
  189. [tags]
  190. );
  191. // TODO(ogi) try to use useApiQuery here
  192. const getTagValues = useCallback(
  193. async tag => {
  194. const tagsValues = await api.requestPromise(
  195. `/organizations/${org.slug}/metrics/tags/${tag.key}/`,
  196. {
  197. query: {
  198. // TODO(ddm): OrganizationMetricsTagDetailsEndpoint does not return values when metric is specified
  199. // metric: mri,
  200. useCase: getUseCaseFromMri(mri),
  201. project: selection.projects,
  202. },
  203. }
  204. );
  205. return tagsValues.map(tv => tv.value);
  206. },
  207. [api, mri, org.slug, selection.projects]
  208. );
  209. const handleChange = useCallback(
  210. (value: string, {validSearch} = {validSearch: true}) => {
  211. if (validSearch) {
  212. onChange(value);
  213. }
  214. },
  215. [onChange]
  216. );
  217. return (
  218. <WideSearchBar
  219. disabled={disabled}
  220. maxMenuHeight={220}
  221. organization={org}
  222. onGetTagValues={getTagValues}
  223. supportedTags={supportedTags}
  224. onClose={handleChange}
  225. onSearch={handleChange}
  226. placeholder={t('Filter by tags')}
  227. defaultQuery={query}
  228. />
  229. );
  230. }
  231. const QueryBuilderWrapper = styled('div')`
  232. display: flex;
  233. flex-direction: column;
  234. `;
  235. const QueryBuilderRow = styled('div')`
  236. padding: ${space(1)};
  237. padding-bottom: 0;
  238. `;
  239. const WideSearchBar = styled(SearchBar)`
  240. width: 100%;
  241. opacity: ${p => (p.disabled ? '0.6' : '1')};
  242. `;
  243. // TODO(ddm): reuse from types/metrics.tsx
  244. type Group = {
  245. by: Record<string, unknown>;
  246. series: Record<string, number[]>;
  247. totals: Record<string, number>;
  248. };
  249. type DisplayProps = MetricsDataProps & {
  250. displayType: MetricDisplayType;
  251. };
  252. function MetricsExplorerDisplayOuter(props?: DisplayProps) {
  253. if (!props?.mri) {
  254. return (
  255. <DisplayWrapper>
  256. <EmptyMessage
  257. icon={<IconSearch size="xxl" />}
  258. title={t('Nothing to show!')}
  259. description={t('Choose a metric to display data.')}
  260. />
  261. </DisplayWrapper>
  262. );
  263. }
  264. return <MetricsExplorerDisplay {...props} />;
  265. }
  266. function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps) {
  267. const router = useRouter();
  268. const {data, isLoading, isError} = useMetricsData(metricsDataProps);
  269. const hiddenSeries = decodeList(router.location.query.hiddenSeries);
  270. const toggleSeriesVisibility = (seriesName: string) => {
  271. if (hiddenSeries.includes(seriesName)) {
  272. router.push({
  273. ...router.location,
  274. query: {
  275. ...router.location.query,
  276. hiddenSeries: hiddenSeries.filter(s => s !== seriesName),
  277. },
  278. });
  279. } else {
  280. router.push({
  281. ...router.location,
  282. query: {
  283. ...router.location.query,
  284. hiddenSeries: [...hiddenSeries, seriesName],
  285. },
  286. });
  287. }
  288. };
  289. if (!data) {
  290. return (
  291. <DisplayWrapper>
  292. {isLoading && <LoadingIndicator />}
  293. {isError && <Alert type="error">{t('Error while fetching metrics data')}</Alert>}
  294. </DisplayWrapper>
  295. );
  296. }
  297. // TODO(ddm): we should move this into the useMetricsData hook
  298. const sorted = sortData(data);
  299. const unit = getUnitFromMRI(Object.keys(data.groups[0]?.series ?? {})[0]); // this assumes that all series have the same unit
  300. const series = sorted.groups.map(g => {
  301. return {
  302. values: Object.values(g.series)[0],
  303. name: getSeriesName(g, data.groups.length === 1, metricsDataProps.groupBy),
  304. };
  305. });
  306. const colors = theme.charts.getColorPalette(series.length);
  307. const chartSeries = series.map((item, i) => ({
  308. seriesName: item.name,
  309. unit,
  310. color: colors[i],
  311. hidden: hiddenSeries.includes(item.name),
  312. data: item.values.map((value, index) => ({
  313. name: sorted.intervals[index],
  314. value,
  315. })),
  316. }));
  317. return (
  318. <DisplayWrapper>
  319. <Chart
  320. series={chartSeries}
  321. displayType={displayType}
  322. operation={metricsDataProps.op}
  323. projects={metricsDataProps.projects}
  324. environments={metricsDataProps.environments}
  325. {...normalizeChartTimeParams(sorted)}
  326. />
  327. <SummaryTable
  328. series={chartSeries}
  329. operation={metricsDataProps.op}
  330. onClick={toggleSeriesVisibility}
  331. />
  332. </DisplayWrapper>
  333. );
  334. }
  335. function getSeriesName(
  336. group: Group,
  337. isOnlyGroup = false,
  338. groupBy: MetricsDataProps['groupBy']
  339. ) {
  340. if (isOnlyGroup && !groupBy?.length) {
  341. return Object.keys(group.series)?.[0] ?? '(none)';
  342. }
  343. return Object.entries(group.by)
  344. .map(([key, value]) => `${key}:${String(value).length ? value : t('none')}`)
  345. .join(', ');
  346. }
  347. function sortData(data: MetricsData): MetricsData {
  348. if (!data.groups.length) {
  349. return data;
  350. }
  351. const key = Object.keys(data.groups[0].totals)[0];
  352. const sortedGroups = data.groups.sort((a, b) =>
  353. a.totals[key] < b.totals[key] ? 1 : -1
  354. );
  355. return {
  356. ...data,
  357. groups: sortedGroups,
  358. };
  359. }
  360. function normalizeChartTimeParams(data: MetricsData) {
  361. const {
  362. start,
  363. end,
  364. utc: utcString,
  365. statsPeriod,
  366. } = normalizeDateTimeParams(data, {
  367. allowEmptyPeriod: true,
  368. allowAbsoluteDatetime: true,
  369. allowAbsolutePageDatetime: true,
  370. });
  371. const utc = utcString === 'true';
  372. if (start && end) {
  373. return utc
  374. ? {
  375. start: moment.utc(start).format(),
  376. end: moment.utc(end).format(),
  377. utc,
  378. }
  379. : {
  380. start: moment(start).utc().format(),
  381. end: moment(end).utc().format(),
  382. utc,
  383. };
  384. }
  385. return {
  386. period: statsPeriod ?? '90d',
  387. };
  388. }
  389. export type Series = {
  390. color: string;
  391. data: {name: string; value: number}[];
  392. seriesName: string;
  393. unit: string;
  394. hidden?: boolean;
  395. };
  396. type ChartProps = {
  397. displayType: MetricDisplayType;
  398. environments: PageFilters['environments'];
  399. projects: PageFilters['projects'];
  400. series: Series[];
  401. end?: string;
  402. operation?: string;
  403. period?: string;
  404. start?: string;
  405. utc?: boolean;
  406. };
  407. function Chart({
  408. series,
  409. displayType,
  410. start,
  411. end,
  412. period,
  413. utc,
  414. operation,
  415. projects,
  416. environments,
  417. }: ChartProps) {
  418. const unit = series[0]?.unit;
  419. const seriesToShow = series.filter(s => !s.hidden);
  420. const chartProps = {
  421. isGroupedByDate: true,
  422. height: 300,
  423. colors: seriesToShow.map(s => s.color),
  424. grid: {top: 20, bottom: 20, left: 20, right: 20},
  425. tooltip: {
  426. valueFormatter: (value: number) => {
  427. return formatMetricsUsingUnitAndOp(value, unit, operation);
  428. },
  429. nameFormatter: mri => getNameFromMRI(mri),
  430. },
  431. yAxis: {
  432. axisLabel: {
  433. formatter: (value: number) => {
  434. return formatMetricsUsingUnitAndOp(value, unit, operation);
  435. },
  436. },
  437. },
  438. };
  439. return (
  440. <Fragment>
  441. <ChartZoom period={period} start={start} end={end} utc={utc}>
  442. {zoomRenderProps => (
  443. <ReleaseSeries
  444. utc={utc}
  445. period={period}
  446. start={zoomRenderProps.start!}
  447. end={zoomRenderProps.end!}
  448. projects={projects}
  449. environments={environments}
  450. preserveQueryParams
  451. >
  452. {({releaseSeries}) => {
  453. const legend = releaseSeries[0]?.markLine?.data?.length
  454. ? Legend({
  455. itemGap: 20,
  456. top: 0,
  457. right: 20,
  458. data: releaseSeries.map(s => s.seriesName),
  459. theme: theme as Theme,
  460. })
  461. : undefined;
  462. return displayType === MetricDisplayType.LINE ? (
  463. <LineChart
  464. series={[...seriesToShow, ...releaseSeries]}
  465. legend={legend}
  466. {...chartProps}
  467. {...zoomRenderProps}
  468. />
  469. ) : displayType === MetricDisplayType.AREA ? (
  470. <AreaChart
  471. series={[...seriesToShow, ...releaseSeries]}
  472. legend={legend}
  473. {...chartProps}
  474. {...zoomRenderProps}
  475. />
  476. ) : (
  477. <BarChart
  478. stacked
  479. series={[...seriesToShow, ...releaseSeries]}
  480. legend={legend}
  481. {...chartProps}
  482. {...zoomRenderProps}
  483. />
  484. );
  485. }}
  486. </ReleaseSeries>
  487. )}
  488. </ChartZoom>
  489. </Fragment>
  490. );
  491. }
  492. const MetricsExplorerPanel = styled(Panel)`
  493. padding-bottom: 0;
  494. `;
  495. const DisplayWrapper = styled('div')`
  496. padding: ${space(1)};
  497. display: flex;
  498. flex-direction: column;
  499. justify-content: center;
  500. `;
  501. export default MetricsExplorer;