Browse Source

feat(ddm): Add metrics explorer from getsentry (#56460)

Matej Minar 1 year ago
parent
commit
ba5781194b
3 changed files with 707 additions and 1 deletions
  1. 167 0
      static/app/utils/metrics.tsx
  2. 4 1
      static/app/views/ddm/ddm.tsx
  3. 536 0
      static/app/views/ddm/metricsExplorer.tsx

+ 167 - 0
static/app/utils/metrics.tsx

@@ -0,0 +1,167 @@
+import {useMemo} from 'react';
+import moment from 'moment';
+
+import {getInterval} from 'sentry/components/charts/utils';
+import {parseStatsPeriod} from 'sentry/components/organizations/timeRangeSelector/utils';
+import {ApiQueryKey, useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type MetricMeta = {
+  mri: string;
+  operations: string[];
+};
+
+export function useMetricsMeta(): Record<string, MetricMeta> {
+  const {slug} = useOrganization();
+  const getKey = (useCase: UseCase): ApiQueryKey => {
+    return [`/organizations/${slug}/metrics/meta/`, {query: {useCase}}];
+  };
+
+  const opts = {
+    staleTime: Infinity,
+  };
+
+  const {data: sessionsMeta = []} = useApiQuery<MetricMeta[]>(getKey('sessions'), opts);
+  const {data: txnsMeta = []} = useApiQuery<MetricMeta[]>(getKey('transactions'), opts);
+  const {data: customMeta = []} = useApiQuery<MetricMeta[]>(getKey('custom'), opts);
+
+  return useMemo(
+    () =>
+      [...sessionsMeta, ...txnsMeta, ...customMeta].reduce((acc, metricMeta) => {
+        return {...acc, [metricMeta.mri]: metricMeta};
+      }, {}),
+    [sessionsMeta, txnsMeta, customMeta]
+  );
+}
+
+type MetricTag = {
+  key: string;
+};
+
+export function useMetricsTags(mri: string) {
+  const {slug} = useOrganization();
+  const useCase = getUseCaseFromMri(mri);
+  return useApiQuery<MetricTag[]>(
+    [`/organizations/${slug}/metrics/tags/`, {query: {metric: mri, useCase}}],
+    {
+      staleTime: Infinity,
+    }
+  );
+}
+
+export function useMetricsTagValues(mri: string, tag: string) {
+  const {slug} = useOrganization();
+  const useCase = getUseCaseFromMri(mri);
+  return useApiQuery<MetricTag[]>(
+    [`/organizations/${slug}/metrics/tags/${tag}`, {query: {useCase}}],
+    {
+      staleTime: Infinity,
+      enabled: !!tag,
+    }
+  );
+}
+
+export type MetricsDataProps = {
+  mri: string;
+  timeRange: any;
+  groupBy?: string[];
+  op?: string;
+  projects?: string[];
+  queryString?: string;
+};
+
+type Group = {
+  by: Record<string, unknown>;
+  series: Record<string, number[]>;
+  totals: Record<string, number>;
+};
+
+export type MetricsData = {
+  end: string;
+  groups: Group[];
+  intervals: string[];
+  meta: MetricMeta[];
+  query: string;
+  start: string;
+};
+
+export function useMetricsData({
+  mri,
+  op,
+  timeRange,
+  projects,
+  queryString,
+  groupBy,
+}: MetricsDataProps) {
+  const {slug} = useOrganization();
+  const useCase = getUseCaseFromMri(mri);
+  const field = op ? `${op}(${mri})` : mri;
+
+  const {start, end} = getUTCTimeRange(timeRange);
+  const interval = getMetricsInterval({start, end});
+
+  const query = getQueryString({projects, queryString});
+
+  const queryToSend = {
+    field,
+    useCase,
+    interval,
+    query,
+    groupBy,
+  };
+
+  return useApiQuery<MetricsData>(
+    [`/organizations/${slug}/metrics/data/`, {query: queryToSend}],
+    {
+      staleTime: 60,
+      retry: 0,
+    }
+  );
+}
+
+function getQueryString({
+  projects = [],
+  queryString = '',
+}: Pick<MetricsDataProps, 'projects' | 'queryString'>): string {
+  const projectQuery = projects.map(p => `project:${p}`).join(' OR ');
+  return [projectQuery, queryString].join(' ');
+}
+
+const getUTCTimeRange = (timeRange: Record<string, any>) => {
+  const absoluteTimeRange = timeRange.relative
+    ? parseStatsPeriod(timeRange.relative)
+    : timeRange;
+
+  return {
+    start: moment(absoluteTimeRange.start).utc().toISOString(),
+    end: moment(absoluteTimeRange.end).utc().toISOString(),
+  };
+};
+
+const getMetricsInterval = (timeRange: Record<string, any>) => {
+  const diff = moment(timeRange.end).diff(moment(timeRange.start), 'days');
+
+  if (diff >= 7 && diff <= 16) {
+    return '2h';
+  }
+  if (diff > 16 && diff <= 30) {
+    return '4h';
+  }
+  if (diff > 32 && diff <= 90) {
+    return '12h';
+  }
+
+  return getInterval(timeRange, 'medium');
+};
+
+type UseCase = 'sessions' | 'transactions' | 'custom';
+
+export function getUseCaseFromMri(mri?: string): UseCase {
+  if (mri?.includes('custom')) {
+    return 'custom';
+  }
+  if (mri?.includes('transactions')) {
+    return 'transactions';
+  }
+  return 'sessions';
+}

+ 4 - 1
static/app/views/ddm/ddm.tsx

@@ -7,6 +7,7 @@ import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionT
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t} from 'sentry/locale';
 import useOrganization from 'sentry/utils/useOrganization';
+import MetricsExplorer from 'sentry/views/ddm/metricsExplorer';
 
 function DDM() {
   const organization = useOrganization();
@@ -33,7 +34,9 @@ function DDM() {
             </Layout.HeaderActions>
           </Layout.Header>
           <Layout.Body>
-            <Layout.Main fullWidth>.</Layout.Main>
+            <Layout.Main fullWidth>
+              <MetricsExplorer />
+            </Layout.Main>
           </Layout.Body>
         </Layout.Page>
       </PageFiltersContainer>

+ 536 - 0
static/app/views/ddm/metricsExplorer.tsx

@@ -0,0 +1,536 @@
+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 PanelHeader from 'sentry/components/panels/panelHeader';
+import PanelTable from 'sentry/components/panels/panelTable';
+import {TimeRangeSelector} from 'sentry/components/timeRangeSelector';
+import {IconSearch} from 'sentry/icons';
+import {space} from 'sentry/styles/space';
+import {MetricsTag, Project, TagCollection} from 'sentry/types';
+import getDynamicText from 'sentry/utils/getDynamicText';
+import {
+  getUseCaseFromMri,
+  MetricsData,
+  MetricsDataProps,
+  useMetricsData,
+  useMetricsMeta,
+  useMetricsTags,
+} from 'sentry/utils/metrics';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import theme from 'sentry/utils/theme';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+const displayTypes = ['Line Chart', 'Bar Chart', 'Area Chart', 'Table'] as const;
+type DisplayType = (typeof displayTypes)[number];
+
+function MetricsExplorer() {
+  const [query, setQuery] = useState<MetricsDataProps>();
+  const [displayType, setDisplayType] = useState<DisplayType>('Line Chart');
+
+  return (
+    <MetricsExplorerPanel>
+      <MetricsExplorerHeader displayType={displayType} setDisplayType={setDisplayType} />
+      <PanelBody>
+        <QueryBuilder setQuery={setQuery} />
+        {query && <MetricsExplorerDisplayOuter displayType={displayType} {...query} />}
+      </PanelBody>
+    </MetricsExplorerPanel>
+  );
+}
+
+type MetricsExplorerHeaderProps = {
+  displayType: DisplayType;
+  setDisplayType: (displayType: DisplayType) => void;
+};
+
+function MetricsExplorerHeader({
+  displayType,
+  setDisplayType,
+}: MetricsExplorerHeaderProps) {
+  return (
+    <PanelHeader>
+      <div>Metrics Explorer</div>
+      <CompactSelect
+        triggerProps={{size: 'xs', prefix: 'Display'}}
+        value={displayType}
+        options={displayTypes.map(opt => ({
+          value: opt,
+          label: opt,
+        }))}
+        onChange={opt => setDisplayType(opt.value as DisplayType)}
+      />
+    </PanelHeader>
+  );
+}
+
+type QueryBuilderProps = {
+  setQuery: (query: MetricsDataProps) => void;
+};
+
+type QueryBuilderState = {
+  groupBy: string[];
+  mri: string;
+  op: string;
+  projects: string[];
+  queryString: string;
+  timeRange: Record<string, any>;
+};
+
+type QueryBuilderAction =
+  | {
+      type: 'mri';
+      value: string;
+    }
+  | {
+      type: 'op';
+      value: string;
+    }
+  | {
+      type: 'projects';
+      value: string[];
+    }
+  | {
+      type: 'groupBy';
+      value: string[];
+    }
+  | {
+      type: 'timeRange';
+      value: Record<string, any>;
+    }
+  | {
+      type: 'queryString';
+      value: string;
+    };
+
+function QueryBuilder({setQuery}: QueryBuilderProps) {
+  const {slug} = useOrganization();
+  const meta = useMetricsMeta();
+
+  const {data: projects = []} = useApiQuery<Project[]>(
+    [`/organizations/${slug}/projects/`],
+    {
+      staleTime: Infinity,
+    }
+  );
+
+  const isAllowedOp = (op: string) =>
+    !['max_timestamp', 'min_timestamp', 'histogram'].includes(op);
+
+  const reducer = (state: QueryBuilderState, action: QueryBuilderAction) => {
+    if (action.type === 'mri') {
+      const operations = meta[`${action.value}`]?.operations.filter(isAllowedOp) || [''];
+      return {...state, mri: action.value, op: operations[0]};
+    }
+    if (['op', 'groupBy', 'projects', 'timeRange', 'queryString'].includes(action.type)) {
+      return {...state, [action.type]: action.value};
+    }
+
+    return state;
+  };
+
+  const [state, dispatch] = useReducer(reducer, {
+    mri: '',
+    op: '',
+    queryString: '',
+    projects: [],
+    groupBy: [],
+    timeRange: {
+      relative: '24h',
+      statsPeriod: '24h',
+    },
+  });
+
+  const {data: tags = []} = useMetricsTags(state.mri);
+
+  useEffect(() => {
+    setQuery(state);
+  }, [state, setQuery]);
+
+  if (!meta) {
+    return null;
+  }
+
+  const selectedMetric = meta[state.mri] || {operations: []};
+
+  return (
+    <QueryBuilderWrapper>
+      <QueryBuilderRow>
+        <PageFilterBar condensed>
+          <CompactSelect
+            searchable
+            triggerProps={{prefix: 'MRI', size: 'sm'}}
+            options={Object.keys(meta).map(mri => ({
+              label: mri,
+              value: mri,
+            }))}
+            value={state.mri}
+            onChange={option => {
+              dispatch({type: 'mri', value: option.value});
+            }}
+          />
+          <CompactSelect
+            triggerProps={{prefix: 'Operation', size: 'sm'}}
+            options={selectedMetric.operations.filter(isAllowedOp).map(op => ({
+              label: op,
+              value: op,
+            }))}
+            value={state.op}
+            onChange={option => dispatch({type: 'op', value: option.value})}
+          />
+          <CompactSelect
+            searchable
+            multiple
+            triggerProps={{prefix: 'Project', size: 'sm'}}
+            options={projects.map(project => ({
+              label: project.slug,
+              value: project.slug,
+            }))}
+            value={state.projects}
+            onChange={options =>
+              dispatch({type: 'projects', value: options.map(o => o.value)})
+            }
+          />
+          <CompactSelect
+            multiple
+            triggerProps={{prefix: 'Group by', size: 'sm'}}
+            options={tags.map(tag => ({
+              label: tag.key,
+              value: tag.key,
+            }))}
+            value={state.groupBy}
+            onChange={options => {
+              dispatch({type: 'groupBy', value: options.map(o => o.value)});
+            }}
+          />
+          <TimeRangeSelector
+            relative={state.timeRange.relative}
+            utc={state.timeRange.utc}
+            start={state.timeRange.start}
+            end={state.timeRange.end}
+            size="sm"
+            onChange={data => {
+              dispatch({type: 'timeRange', value: normalizeDateTimeParams(data)});
+            }}
+          />
+        </PageFilterBar>
+      </QueryBuilderRow>
+      <QueryBuilderRow>
+        <MetricSearchBar
+          tags={tags}
+          mri={state.mri}
+          disabled={!state.mri}
+          onChange={data => {
+            dispatch({type: 'queryString', value: data});
+          }}
+        />
+      </QueryBuilderRow>
+    </QueryBuilderWrapper>
+  );
+}
+
+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 (
+    <WideSearchBar
+      disabled={disabled}
+      maxMenuHeight={220}
+      organization={org}
+      onGetTagValues={getTagValues}
+      supportedTags={supportedTags}
+      onClose={handleChange}
+      onSearch={handleChange}
+      placeholder="Search for tags"
+    />
+  );
+}
+
+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<string, unknown>;
+  series: Record<string, number[]>;
+  totals: Record<string, number>;
+};
+
+type DisplayProps = MetricsDataProps & {
+  displayType: DisplayType;
+};
+
+function MetricsExplorerDisplayOuter(props?: DisplayProps) {
+  if (!props?.mri) {
+    return (
+      <DisplayWrapper>
+        <EmptyMessage icon={<IconSearch size="xxl" />}>
+          Nothing to show. Choose an MRI to display data!
+        </EmptyMessage>
+      </DisplayWrapper>
+    );
+  }
+
+  return <MetricsExplorerDisplay {...props} />;
+}
+
+function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps) {
+  const {data, isLoading, isError} = useMetricsData(metricsDataProps);
+
+  if (!data) {
+    return (
+      <DisplayWrapper>
+        {isLoading && <LoadingIndicator overlay />}
+        {isError && <Alert type="error">Error while fetching metrics data</Alert>}
+      </DisplayWrapper>
+    );
+  }
+
+  const sorted = sortData(data);
+
+  return (
+    <DisplayWrapper>
+      {displayType === 'Table' ? (
+        <Table data={sorted} />
+      ) : (
+        <Chart data={sorted} displayType={displayType} />
+      )}
+    </DisplayWrapper>
+  );
+}
+
+function getSeriesName(group: Group, isOnlyGroup = false) {
+  if (isOnlyGroup) {
+    return Object.keys(group.series)?.[0] ?? '(none)';
+  }
+
+  return Object.values(group.by).join('-') ?? '(none)';
+}
+
+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: DisplayType}) {
+  const {start, end, period, utc} = normalizeChartTimeParams(data);
+
+  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,
+    colors: [
+      theme.purple400,
+      theme.yellow400,
+      theme.pink400,
+      theme.green400,
+      theme.red400,
+      theme.blue400,
+      ...theme.charts.colors,
+    ],
+    height: 300,
+    legend: Legend({
+      itemGap: 20,
+      bottom: 20,
+      data: chartSeries.map(s => s.seriesName),
+      theme: theme as Theme,
+    }),
+    grid: {top: 30, bottom: 40, left: 20, right: 20},
+  };
+
+  return (
+    <Fragment>
+      {getDynamicText({
+        value: (
+          <ChartZoom period={period} start={start} end={end} utc={utc}>
+            {zoomRenderProps =>
+              displayType === 'Line Chart' ? (
+                <LineChart {...chartProps} {...zoomRenderProps} />
+              ) : displayType === 'Area Chart' ? (
+                <AreaChart {...chartProps} {...zoomRenderProps} />
+              ) : (
+                <BarChart stacked {...chartProps} {...zoomRenderProps} />
+              )
+            }
+          </ChartZoom>
+        ),
+        fixed: 'Metrics Chart',
+      })}
+    </Fragment>
+  );
+}
+
+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 (
+    <SeriesTable headers={Object.keys(rows[0])}>
+      {rows.map(row => (
+        <Fragment key={row.id}>
+          {Object.values(row).map((value, idx) => (
+            <Cell key={`${row.id}-${idx}`}>{value}</Cell>
+          ))}
+        </Fragment>
+      ))}
+    </SeriesTable>
+  );
+}
+
+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;