metricsExplorer.tsx 14 KB

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