metricsExplorer.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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 PanelHeader from 'sentry/components/panels/panelHeader';
  20. import PanelTable from 'sentry/components/panels/panelTable';
  21. import {IconSearch} from 'sentry/icons';
  22. import {space} from 'sentry/styles/space';
  23. import {MetricsTag, TagCollection} from 'sentry/types';
  24. import getDynamicText from 'sentry/utils/getDynamicText';
  25. import {
  26. getUseCaseFromMri,
  27. MetricsData,
  28. MetricsDataProps,
  29. useMetricsData,
  30. useMetricsMeta,
  31. useMetricsTags,
  32. } from 'sentry/utils/metrics';
  33. import theme from 'sentry/utils/theme';
  34. import useApi from 'sentry/utils/useApi';
  35. import useOrganization from 'sentry/utils/useOrganization';
  36. import usePageFilters from 'sentry/utils/usePageFilters';
  37. import useProjects from 'sentry/utils/useProjects';
  38. const displayTypes = ['Line Chart', 'Bar Chart', 'Area Chart', 'Table'] as const;
  39. type DisplayType = (typeof displayTypes)[number];
  40. const useProjectSelectionSlugs = () => {
  41. const {selection} = usePageFilters();
  42. const {projects} = useProjects();
  43. return useMemo(
  44. () =>
  45. selection.projects
  46. .map(id => projects.find(p => p.id === id.toString())?.slug)
  47. .filter(Boolean) as string[],
  48. [projects, selection.projects]
  49. );
  50. };
  51. function MetricsExplorer() {
  52. const {selection} = usePageFilters();
  53. const slugs = useProjectSelectionSlugs();
  54. const [query, setQuery] = useState<QueryBuilderState>();
  55. const [displayType, setDisplayType] = useState<DisplayType>('Line Chart');
  56. return (
  57. <MetricsExplorerPanel>
  58. <MetricsExplorerHeader displayType={displayType} setDisplayType={setDisplayType} />
  59. <PanelBody>
  60. <QueryBuilder setQuery={setQuery} />
  61. {query && (
  62. <MetricsExplorerDisplayOuter
  63. displayType={displayType}
  64. datetime={selection.datetime}
  65. projects={slugs}
  66. {...query}
  67. />
  68. )}
  69. </PanelBody>
  70. </MetricsExplorerPanel>
  71. );
  72. }
  73. type MetricsExplorerHeaderProps = {
  74. displayType: DisplayType;
  75. setDisplayType: (displayType: DisplayType) => void;
  76. };
  77. function MetricsExplorerHeader({
  78. displayType,
  79. setDisplayType,
  80. }: MetricsExplorerHeaderProps) {
  81. return (
  82. <PanelHeader>
  83. <div>Metrics Explorer</div>
  84. <CompactSelect
  85. triggerProps={{size: 'xs', prefix: 'Display'}}
  86. value={displayType}
  87. options={displayTypes.map(opt => ({
  88. value: opt,
  89. label: opt,
  90. }))}
  91. onChange={opt => setDisplayType(opt.value as DisplayType)}
  92. />
  93. </PanelHeader>
  94. );
  95. }
  96. type QueryBuilderProps = {
  97. setQuery: (query: QueryBuilderState) => void;
  98. };
  99. type QueryBuilderState = {
  100. groupBy: string[];
  101. mri: string;
  102. op: string;
  103. queryString: string;
  104. };
  105. type QueryBuilderAction =
  106. | {
  107. type: 'mri';
  108. value: string;
  109. }
  110. | {
  111. type: 'op';
  112. value: string;
  113. }
  114. | {
  115. type: 'groupBy';
  116. value: string[];
  117. }
  118. | {
  119. type: 'queryString';
  120. value: string;
  121. };
  122. function QueryBuilder({setQuery}: QueryBuilderProps) {
  123. const meta = useMetricsMeta();
  124. const isAllowedOp = (op: string) =>
  125. !['max_timestamp', 'min_timestamp', 'histogram'].includes(op);
  126. const reducer = (state: QueryBuilderState, action: QueryBuilderAction) => {
  127. if (action.type === 'mri') {
  128. const availableOps = meta[`${action.value}`]?.operations.filter(isAllowedOp);
  129. const selectedOp = availableOps.includes(state.op) ? state.op : availableOps[0];
  130. return {...state, mri: action.value, op: selectedOp};
  131. }
  132. if (['op', 'groupBy', 'queryString'].includes(action.type)) {
  133. return {...state, [action.type]: action.value};
  134. }
  135. return state;
  136. };
  137. const [state, dispatch] = useReducer(reducer, {
  138. mri: '',
  139. op: '',
  140. queryString: '',
  141. groupBy: [],
  142. });
  143. const {data: tags = []} = useMetricsTags(state.mri);
  144. useEffect(() => {
  145. setQuery(state);
  146. }, [state, setQuery]);
  147. if (!meta) {
  148. return null;
  149. }
  150. const selectedMetric = meta[state.mri] || {operations: []};
  151. return (
  152. <QueryBuilderWrapper>
  153. <QueryBuilderRow>
  154. <PageFilterBar condensed>
  155. <CompactSelect
  156. searchable
  157. triggerProps={{prefix: 'MRI', size: 'sm'}}
  158. options={Object.keys(meta).map(mri => ({
  159. label: mri,
  160. value: mri,
  161. }))}
  162. value={state.mri}
  163. onChange={option => {
  164. dispatch({type: 'mri', value: option.value});
  165. }}
  166. />
  167. <CompactSelect
  168. triggerProps={{prefix: '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: '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="Search for 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: DisplayType;
  267. };
  268. function MetricsExplorerDisplayOuter(props?: DisplayProps) {
  269. if (!props?.mri) {
  270. return (
  271. <DisplayWrapper>
  272. <EmptyMessage icon={<IconSearch size="xxl" />}>
  273. Nothing to show. Choose an MRI to display data!
  274. </EmptyMessage>
  275. </DisplayWrapper>
  276. );
  277. }
  278. return <MetricsExplorerDisplay {...props} />;
  279. }
  280. function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps) {
  281. const {data, isLoading, isError} = useMetricsData(metricsDataProps);
  282. if (!data) {
  283. return (
  284. <DisplayWrapper>
  285. {isLoading && <LoadingIndicator />}
  286. {isError && <Alert type="error">Error while fetching metrics data</Alert>}
  287. </DisplayWrapper>
  288. );
  289. }
  290. const sorted = sortData(data);
  291. return (
  292. <DisplayWrapper>
  293. {displayType === 'Table' ? (
  294. <Table data={sorted} />
  295. ) : (
  296. <Chart data={sorted} displayType={displayType} />
  297. )}
  298. </DisplayWrapper>
  299. );
  300. }
  301. function getSeriesName(group: Group, isOnlyGroup = false) {
  302. if (isOnlyGroup) {
  303. return Object.keys(group.series)?.[0] ?? '(none)';
  304. }
  305. return Object.values(group.by).join('-') ?? '(none)';
  306. }
  307. function sortData(data: MetricsData): MetricsData {
  308. if (!data.groups.length) {
  309. return data;
  310. }
  311. const key = Object.keys(data.groups[0].totals)[0];
  312. const sortedGroups = data.groups.sort((a, b) =>
  313. a.totals[key] < b.totals[key] ? 1 : -1
  314. );
  315. return {
  316. ...data,
  317. groups: sortedGroups,
  318. };
  319. }
  320. function normalizeChartTimeParams(data: MetricsData) {
  321. const {
  322. start,
  323. end,
  324. utc: utcString,
  325. statsPeriod,
  326. } = normalizeDateTimeParams(data, {
  327. allowEmptyPeriod: true,
  328. allowAbsoluteDatetime: true,
  329. allowAbsolutePageDatetime: true,
  330. });
  331. const utc = utcString === 'true';
  332. if (start && end) {
  333. return utc
  334. ? {
  335. start: moment.utc(start).format(),
  336. end: moment.utc(end).format(),
  337. utc,
  338. }
  339. : {
  340. start: moment(start).utc().format(),
  341. end: moment(end).utc().format(),
  342. utc,
  343. };
  344. }
  345. return {
  346. period: statsPeriod ?? '90d',
  347. };
  348. }
  349. function Chart({data, displayType}: {data: MetricsData; displayType: DisplayType}) {
  350. const {start, end, period, utc} = normalizeChartTimeParams(data);
  351. const series = data.groups.map(g => {
  352. return {
  353. values: Object.values(g.series)[0],
  354. name: getSeriesName(g, data.groups.length === 1),
  355. };
  356. });
  357. const chartSeries = series.map(item => ({
  358. seriesName: item.name,
  359. data: item.values.map((value, index) => ({
  360. name: data.intervals[index],
  361. value,
  362. })),
  363. }));
  364. const chartProps = {
  365. isGroupedByDate: true,
  366. series: chartSeries,
  367. height: 300,
  368. legend: Legend({
  369. itemGap: 20,
  370. bottom: 20,
  371. data: chartSeries.map(s => s.seriesName),
  372. theme: theme as Theme,
  373. }),
  374. grid: {top: 30, bottom: 40, left: 20, right: 20},
  375. };
  376. return (
  377. <Fragment>
  378. {getDynamicText({
  379. value: (
  380. <ChartZoom period={period} start={start} end={end} utc={utc}>
  381. {zoomRenderProps =>
  382. displayType === 'Line Chart' ? (
  383. <LineChart {...chartProps} {...zoomRenderProps} />
  384. ) : displayType === 'Area Chart' ? (
  385. <AreaChart {...chartProps} {...zoomRenderProps} />
  386. ) : (
  387. <BarChart stacked {...chartProps} {...zoomRenderProps} />
  388. )
  389. }
  390. </ChartZoom>
  391. ),
  392. fixed: 'Metrics Chart',
  393. })}
  394. </Fragment>
  395. );
  396. }
  397. function Table({data}: {data: MetricsData}) {
  398. const rows = data.intervals.map((interval, index) => {
  399. const row = {
  400. id: moment(interval).utc().format(),
  401. };
  402. data.groups.forEach(group => {
  403. const seriesName = getSeriesName(group, data.groups.length === 1);
  404. Object.values(group.series).forEach(values => {
  405. row[seriesName] = values[index];
  406. });
  407. });
  408. return row;
  409. });
  410. return (
  411. <SeriesTable headers={Object.keys(rows[0])}>
  412. {rows.map(row => (
  413. <Fragment key={row.id}>
  414. {Object.values(row).map((value, idx) => (
  415. <Cell key={`${row.id}-${idx}`}>{value}</Cell>
  416. ))}
  417. </Fragment>
  418. ))}
  419. </SeriesTable>
  420. );
  421. }
  422. const SeriesTable = styled(PanelTable)`
  423. max-height: 290px;
  424. margin-bottom: 0;
  425. border: none;
  426. border-radius: 0;
  427. border-bottom: 1px;
  428. `;
  429. const Cell = styled('div')`
  430. padding: ${space(0.5)} 0 ${space(0.5)} ${space(1)};
  431. `;
  432. const MetricsExplorerPanel = styled(Panel)`
  433. padding-bottom: 0;
  434. `;
  435. const DisplayWrapper = styled('div')`
  436. padding: ${space(1)};
  437. height: 300px;
  438. display: flex;
  439. flex-direction: column;
  440. justify-content: center;
  441. `;
  442. export default MetricsExplorer;