metricsExplorer.tsx 13 KB

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