import {Fragment, useCallback, useEffect, useMemo, useReducer, useState} from 'react'; import {Theme} from '@emotion/react'; import styled from '@emotion/styled'; import moment from 'moment'; import Alert from 'sentry/components/alert'; import {AreaChart} from 'sentry/components/charts/areaChart'; import {BarChart} from 'sentry/components/charts/barChart'; import ChartZoom from 'sentry/components/charts/chartZoom'; import Legend from 'sentry/components/charts/components/legend'; import {LineChart} from 'sentry/components/charts/lineChart'; import {CompactSelect} from 'sentry/components/compactSelect'; import EmptyMessage from 'sentry/components/emptyMessage'; import SearchBar from 'sentry/components/events/searchBar'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelTable from 'sentry/components/panels/panelTable'; import Tag from 'sentry/components/tag'; import {IconSearch} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {MetricsTag, TagCollection} from 'sentry/types'; import { defaultMetricDisplayType, getNameFromMRI, getReadableMetricType, getUnitFromMRI, getUseCaseFromMri, MetricDisplayType, MetricsData, MetricsDataProps, tooltipFormatterUsingUnit, useMetricsData, useMetricsMeta, useMetricsTags, } from 'sentry/utils/metrics'; import theme from 'sentry/utils/theme'; import useApi from 'sentry/utils/useApi'; import useKeyPress from 'sentry/utils/useKeyPress'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; import useRouter from 'sentry/utils/useRouter'; const useProjectSelectionSlugs = () => { const {selection} = usePageFilters(); const {projects} = useProjects(); return useMemo( () => selection.projects .map(id => projects.find(p => p.id === id.toString())?.slug) .filter(Boolean) as string[], [projects, selection.projects] ); }; function MetricsExplorer() { const {selection} = usePageFilters(); const slugs = useProjectSelectionSlugs(); const router = useRouter(); const [query, setQuery] = useState(); return ( {query && ( )} ); } type QueryBuilderProps = { setQuery: (query: QueryBuilderState) => void; }; type QueryBuilderState = { groupBy: string[]; mri: string; op: string; queryString: string; }; type QueryBuilderAction = | { type: 'mri'; value: string; } | { type: 'op'; value: string; } | { type: 'groupBy'; value: string[]; } | { type: 'queryString'; value: string; }; function QueryBuilder({setQuery}: QueryBuilderProps) { const meta = useMetricsMeta(); const mriModeKeyPressed = useKeyPress('`', undefined, true); const [mriMode, setMriMode] = useState(false); useEffect(() => { if (mriModeKeyPressed) { setMriMode(!mriMode); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mriModeKeyPressed]); const isAllowedOp = (op: string) => !['max_timestamp', 'min_timestamp', 'histogram'].includes(op); const reducer = (state: QueryBuilderState, action: QueryBuilderAction) => { if (action.type === 'mri') { const availableOps = meta[`${action.value}`]?.operations.filter(isAllowedOp); const selectedOp = availableOps.includes(state.op) ? state.op : availableOps[0]; return {...state, mri: action.value, op: selectedOp}; } if (['op', 'groupBy', 'queryString'].includes(action.type)) { return {...state, [action.type]: action.value}; } return state; }; const [state, dispatch] = useReducer(reducer, { mri: '', op: '', queryString: '', groupBy: [], }); const {data: tags = []} = useMetricsTags(state.mri); useEffect(() => { setQuery(state); }, [state, setQuery]); if (!meta) { return null; } const selectedMetric = meta[state.mri] || {operations: []}; return ( (mriMode ? true : metric.mri.includes(':custom/'))) .map(metric => ({ label: mriMode ? metric.mri : metric.name, value: metric.mri, trailingItems: mriMode ? undefined : ( {getReadableMetricType(metric.type)} {metric.unit} ), }))} value={state.mri} onChange={option => { dispatch({type: 'mri', value: option.value}); }} /> ({ label: op, value: op, }))} value={state.op} onChange={option => dispatch({type: 'op', value: option.value})} /> ({ label: tag.key, value: tag.key, }))} value={state.groupBy} onChange={options => { dispatch({type: 'groupBy', value: options.map(o => o.value)}); }} /> { dispatch({type: 'queryString', value: data}); }} /> ); } type MetricSearchBarProps = { mri: string; onChange: (value: string) => void; tags: MetricsTag[]; disabled?: boolean; }; function MetricSearchBar({tags, mri, disabled, onChange}: MetricSearchBarProps) { const org = useOrganization(); const api = useApi(); const supportedTags: TagCollection = useMemo( () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}), [tags] ); // TODO(ogi) try to use useApiQuery here const getTagValues = useCallback( async tag => { const tagsValues = await api.requestPromise( `/organizations/${org.slug}/metrics/tags/${tag.key}/`, {query: {useCase: getUseCaseFromMri(mri)}} ); return tagsValues.map(tv => tv.value); }, [api, mri, org.slug] ); const handleChange = useCallback( (value: string, {validSearch} = {validSearch: true}) => { if (validSearch) { onChange(value); } }, [onChange] ); return ( ); } const QueryBuilderWrapper = styled('div')` display: flex; flex-direction: column; `; const QueryBuilderRow = styled('div')` padding: ${space(1)}; padding-bottom: 0; `; const WideSearchBar = styled(SearchBar)` width: 100%; opacity: ${p => (p.disabled ? '0.6' : '1')}; `; type Group = { by: Record; series: Record; totals: Record; }; type DisplayProps = MetricsDataProps & { displayType: MetricDisplayType; }; function MetricsExplorerDisplayOuter(props?: DisplayProps) { if (!props?.mri) { return ( } title={t('Nothing to show!')} description={t('Choose a metric to display data.')} /> ); } return ; } function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps) { const {data, isLoading, isError} = useMetricsData(metricsDataProps); if (!data) { return ( {isLoading && } {isError && {t('Error while fetching metrics data')}} ); } const sorted = sortData(data); return ( {displayType === MetricDisplayType.TABLE ? ( ) : ( )} ); } function getSeriesName(group: Group, isOnlyGroup = false) { if (isOnlyGroup) { return Object.keys(group.series)?.[0] ?? '(none)'; } return Object.entries(group.by) .map(([key, value]) => `${key}:${String(value).length ? value : t('none')}`) .join(', '); } function sortData(data: MetricsData): MetricsData { if (!data.groups.length) { return data; } const key = Object.keys(data.groups[0].totals)[0]; const sortedGroups = data.groups.sort((a, b) => a.totals[key] < b.totals[key] ? 1 : -1 ); return { ...data, groups: sortedGroups, }; } function normalizeChartTimeParams(data: MetricsData) { const { start, end, utc: utcString, statsPeriod, } = normalizeDateTimeParams(data, { allowEmptyPeriod: true, allowAbsoluteDatetime: true, allowAbsolutePageDatetime: true, }); const utc = utcString === 'true'; if (start && end) { return utc ? { start: moment.utc(start).format(), end: moment.utc(end).format(), utc, } : { start: moment(start).utc().format(), end: moment(end).utc().format(), utc, }; } return { period: statsPeriod ?? '90d', }; } function Chart({data, displayType}: {data: MetricsData; displayType: MetricDisplayType}) { const {start, end, period, utc} = normalizeChartTimeParams(data); const unit = getUnitFromMRI(Object.keys(data.groups[0].series)[0]); // this assumes that all series have the same unit const series = data.groups.map(g => { return { values: Object.values(g.series)[0], name: getSeriesName(g, data.groups.length === 1), }; }); const chartSeries = series.map(item => ({ seriesName: item.name, data: item.values.map((value, index) => ({ name: data.intervals[index], value, })), })); const chartProps = { isGroupedByDate: true, series: chartSeries, height: 300, legend: Legend({ itemGap: 20, bottom: 20, data: chartSeries.map(s => s.seriesName), theme: theme as Theme, formatter: mri => getNameFromMRI(mri), }), grid: {top: 30, bottom: 40, left: 20, right: 20}, tooltip: { valueFormatter: (value: number) => tooltipFormatterUsingUnit(value, unit), nameFormatter: mri => getNameFromMRI(mri), }, yAxis: { axisLabel: { formatter: (value: number) => tooltipFormatterUsingUnit(value, unit), }, }, }; return ( {zoomRenderProps => displayType === MetricDisplayType.LINE ? ( ) : displayType === MetricDisplayType.AREA ? ( ) : ( ) } ); } function Table({data}: {data: MetricsData}) { const rows = data.intervals.map((interval, index) => { const row = { id: moment(interval).utc().format(), }; data.groups.forEach(group => { const seriesName = getSeriesName(group, data.groups.length === 1); Object.values(group.series).forEach(values => { row[seriesName] = values[index]; }); }); return row; }); return ( {rows.map(row => ( {Object.values(row).map((value, idx) => ( {value} ))} ))} ); } const SeriesTable = styled(PanelTable)` max-height: 290px; margin-bottom: 0; border: none; border-radius: 0; border-bottom: 1px; `; const Cell = styled('div')` padding: ${space(0.5)} 0 ${space(0.5)} ${space(1)}; `; const MetricsExplorerPanel = styled(Panel)` padding-bottom: 0; `; const DisplayWrapper = styled('div')` padding: ${space(1)}; height: 300px; display: flex; flex-direction: column; justify-content: center; `; export default MetricsExplorer;