Browse Source

feat(ddm): Readable MRIs, power mode, unit formatters support, polishes (#56716)

Matej Minar 1 year ago
parent
commit
e87a71055d

+ 3 - 0
fixtures/js-stubs/metrics.ts

@@ -11,6 +11,7 @@ export function MetricsField(
     start: '2021-12-01T16:15:00Z',
     end: '2021-12-02T16:15:00Z',
     query: '',
+    meta: [],
     intervals: [
       '2021-12-01T16:15:00Z',
       '2021-12-01T16:30:00Z',
@@ -155,6 +156,7 @@ export function MetricsTotalCountByReleaseIn24h(): MetricsApiResponse {
     end: '2021-12-02T16:15:00Z',
     query:
       'release:7a82c130be9143361f20bc77252df783cf91e4fc OR release:e102abb2c46e7fe8686441091005c12aed90da99',
+    meta: [],
     intervals: [
       '2021-03-17T10:00:00Z',
       '2021-03-17T11:00:00Z',
@@ -211,6 +213,7 @@ export function MetricsSessionUserCountByStatusByRelease(): MetricsApiResponse {
     start: '2022-01-15T00:00:00Z',
     end: '2022-01-29T00:00:00Z',
     query: '',
+    meta: [],
     intervals: [
       '2022-01-15T00:00:00Z',
       '2022-01-16T00:00:00Z',

+ 4 - 1
static/app/types/metrics.tsx

@@ -19,6 +19,7 @@ export type MetricsApiResponse = {
     totals?: Record<string, number | null>;
   }[];
   intervals: string[];
+  meta: MetricsMeta[];
   query: string;
   start: string;
 };
@@ -35,9 +36,11 @@ export type MetricsTagValue = {
 };
 
 export type MetricsMeta = {
+  mri: string;
   name: string;
   operations: MetricsOperation[];
-  type: MetricsType;
+  type: MetricsType; // TODO(ddm): I think this is wrong, api returns "c" instead of "counter"
+  unit: string;
 };
 
 export type MetricsMetaCollection = Record<string, MetricsMeta>;

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

@@ -2,16 +2,32 @@ import {useMemo} from 'react';
 import moment from 'moment';
 
 import {getInterval} from 'sentry/components/charts/utils';
+import {t} from 'sentry/locale';
+import {defined, formatBytesBase2, formatBytesBase10} from 'sentry/utils';
+import {formatPercentage, getDuration} from 'sentry/utils/formatters';
 import {ApiQueryKey, useApiQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 
 import {PageFilters} from '../types/core';
 
+// TODO(ddm): reuse from types/metrics.tsx
 type MetricMeta = {
   mri: string;
+  name: string;
   operations: string[];
+  type: string;
+  unit: string;
 };
 
+export enum MetricDisplayType {
+  LINE = 'line',
+  AREA = 'area',
+  BAR = 'bar',
+  TABLE = 'table',
+}
+
+export const defaultMetricDisplayType = MetricDisplayType.LINE;
+
 export function useMetricsMeta(): Record<string, MetricMeta> {
   const {slug} = useOrganization();
   const getKey = (useCase: UseCase): ApiQueryKey => {
@@ -62,6 +78,7 @@ export function useMetricsTagValues(mri: string, tag: string) {
   );
 }
 
+// TODO(ddm): reuse from types/metrics.tsx
 export type MetricsDataProps = {
   datetime: PageFilters['datetime'];
   mri: string;
@@ -71,12 +88,14 @@ export type MetricsDataProps = {
   queryString?: string;
 };
 
+// TODO(ddm): reuse from types/metrics.tsx
 type Group = {
   by: Record<string, unknown>;
   series: Record<string, number[]>;
   totals: Record<string, number>;
 };
 
+// TODO(ddm): reuse from types/metrics.tsx
 export type MetricsData = {
   end: string;
   groups: Group[];
@@ -158,3 +177,90 @@ export function getUseCaseFromMri(mri?: string): UseCase {
   }
   return 'sessions';
 }
+
+const metricTypeToReadable = {
+  c: t('counter'),
+  g: t('gauge'),
+  d: t('distribution'),
+  s: t('set'),
+  e: t('derived'),
+};
+
+// Converts from "c" to "counter"
+export function getReadableMetricType(type) {
+  return metricTypeToReadable[type] ?? t('unknown');
+}
+
+const noUnit = 'none';
+
+export function getUnitFromMRI(mri?: string) {
+  if (!mri) {
+    return noUnit;
+  }
+
+  return mri.split('@').pop() ?? noUnit;
+}
+
+export function getNameFromMRI(mri: string) {
+  return mri.match(/^[a-z]:\w+\/(.+)(?:@\w+)$/)?.[1] ?? mri;
+}
+
+export function tooltipFormatterUsingUnit(value: number | null, unit: string) {
+  if (!defined(value)) {
+    return '\u2014';
+  }
+
+  switch (unit) {
+    case 'nanosecond':
+      return getDuration(value / 1000000000, 2, true);
+    case 'microsecond':
+      return getDuration(value / 1000000, 2, true);
+    case 'millisecond':
+      return getDuration(value / 1000, 2, true);
+    case 'second':
+      return getDuration(value, 2, true);
+    case 'minute':
+      return getDuration(value * 60, 2, true);
+    case 'hour':
+      return getDuration(value * 60 * 60, 2, true);
+    case 'day':
+      return getDuration(value * 60 * 60 * 24, 2, true);
+    case 'week':
+      return getDuration(value * 60 * 60 * 24 * 7, 2, true);
+    case 'ratio':
+      return formatPercentage(value, 2);
+    case 'percent':
+      return formatPercentage(value / 100, 2);
+    case 'bit':
+      return formatBytesBase2(value / 8);
+    case 'byte':
+      return formatBytesBase10(value);
+    case 'kibibyte':
+      return formatBytesBase2(value * 1024);
+    case 'kilobyte':
+      return formatBytesBase10(value, 1);
+    case 'mebibyte':
+      return formatBytesBase2(value * 1024 ** 2);
+    case 'megabyte':
+      return formatBytesBase10(value, 2);
+    case 'gibibyte':
+      return formatBytesBase2(value * 1024 ** 3);
+    case 'gigabyte':
+      return formatBytesBase10(value, 3);
+    case 'tebibyte':
+      return formatBytesBase2(value * 1024 ** 4);
+    case 'terabyte':
+      return formatBytesBase10(value, 4);
+    case 'pebibyte':
+      return formatBytesBase2(value * 1024 ** 5);
+    case 'petabyte':
+      return formatBytesBase10(value, 5);
+    case 'exbibyte':
+      return formatBytesBase2(value * 1024 ** 6);
+    case 'exabyte':
+      return formatBytesBase10(value, 6);
+    case 'none':
+    default:
+      return value.toLocaleString();
+  }
+}

+ 16 - 6
static/app/utils/useKeyPress.tsx

@@ -3,20 +3,30 @@ import {useEffect, useState} from 'react';
 /**
  * Hook to detect when a specific key is being pressed
  */
-const useKeyPress = (targetKey: string, target?: HTMLElement) => {
+const useKeyPress = (
+  targetKey: string,
+  target?: HTMLElement,
+  preventDefault?: boolean
+) => {
   const [keyPressed, setKeyPressed] = useState(false);
   const current = target ?? document.body;
 
   useEffect(() => {
-    const downHandler = ({key}: KeyboardEvent) => {
-      if (key === targetKey) {
+    const downHandler = (event: KeyboardEvent) => {
+      if (event.key === targetKey) {
         setKeyPressed(true);
+        if (preventDefault) {
+          event.preventDefault();
+        }
       }
     };
 
-    const upHandler = ({key}: KeyboardEvent) => {
-      if (key === targetKey) {
+    const upHandler = (event: KeyboardEvent) => {
+      if (event.key === targetKey) {
         setKeyPressed(false);
+        if (preventDefault) {
+          event.preventDefault();
+        }
       }
     };
 
@@ -27,7 +37,7 @@ const useKeyPress = (targetKey: string, target?: HTMLElement) => {
       current.removeEventListener('keydown', downHandler);
       current.removeEventListener('keyup', upHandler);
     };
-  }, [targetKey, current]);
+  }, [targetKey, current, preventDefault]);
 
   return keyPressed;
 };

+ 43 - 0
static/app/views/ddm/ddm.tsx

@@ -1,6 +1,7 @@
 import styled from '@emotion/styled';
 
 import ButtonBar from 'sentry/components/buttonBar';
+import {CompactSelect} from 'sentry/components/compactSelect';
 import FeatureBadge from 'sentry/components/featureBadge';
 import {FeatureFeedback} from 'sentry/components/featureFeedback';
 import * as Layout from 'sentry/components/layouts/thirds';
@@ -12,11 +13,14 @@ import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionT
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import {defaultMetricDisplayType, MetricDisplayType} from 'sentry/utils/metrics';
 import useOrganization from 'sentry/utils/useOrganization';
+import useRouter from 'sentry/utils/useRouter';
 import MetricsExplorer from 'sentry/views/ddm/metricsExplorer';
 
 function DDM() {
   const organization = useOrganization();
+  const router = useRouter();
 
   return (
     <SentryDocumentTitle title={t('DDM')} orgSlug={organization.slug}>
@@ -46,6 +50,39 @@ function DDM() {
                   <ProjectPageFilter />
                   <DatePageFilter />
                 </PageFilterBar>
+                <CompactSelect
+                  triggerProps={{prefix: t('Display')}}
+                  value={router.location.query.display ?? defaultMetricDisplayType}
+                  options={[
+                    {
+                      value: MetricDisplayType.LINE,
+                      label: t('Line Chart'),
+                    },
+                    {
+                      value: MetricDisplayType.AREA,
+                      label: t('Area Chart'),
+                    },
+                    {
+                      value: MetricDisplayType.BAR,
+                      label: t('Bar Chart'),
+                    },
+                    // TODO(ddm): Skipping this one for now
+                    // {
+                    //   value: MetricDisplayType.TABLE,
+                    //   label: t('Table Chart'),
+                    // },
+                  ]}
+                  onChange={({value}) => {
+                    router.push({
+                      ...router.location,
+                      query: {
+                        ...router.location.query,
+                        cursor: undefined,
+                        display: value,
+                      },
+                    });
+                  }}
+                />
               </PaddedContainer>
               <MetricsExplorer />
             </Layout.Main>
@@ -58,6 +95,12 @@ function DDM() {
 
 export const PaddedContainer = styled('div')`
   margin-bottom: ${space(2)};
+  display: grid;
+  grid-template: 1fr / 1fr max-content;
+  gap: ${space(1)};
+  @media (max-width: ${props => props.theme.breakpoints.small}) {
+    grid-template: 1fr 1fr / 1fr;
+  }
 `;
 
 export default DDM;

+ 74 - 67
static/app/views/ddm/metricsExplorer.tsx

@@ -17,28 +17,33 @@ 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 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 getDynamicText from 'sentry/utils/getDynamicText';
 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';
-
-const displayTypes = ['Line Chart', 'Bar Chart', 'Area Chart', 'Table'] as const;
-type DisplayType = (typeof displayTypes)[number];
+import useRouter from 'sentry/utils/useRouter';
 
 const useProjectSelectionSlugs = () => {
   const {selection} = usePageFilters();
@@ -57,18 +62,17 @@ function MetricsExplorer() {
   const {selection} = usePageFilters();
 
   const slugs = useProjectSelectionSlugs();
+  const router = useRouter();
 
   const [query, setQuery] = useState<QueryBuilderState>();
-  const [displayType, setDisplayType] = useState<DisplayType>('Line Chart');
 
   return (
     <MetricsExplorerPanel>
-      <MetricsExplorerHeader displayType={displayType} setDisplayType={setDisplayType} />
       <PanelBody>
         <QueryBuilder setQuery={setQuery} />
         {query && (
           <MetricsExplorerDisplayOuter
-            displayType={displayType}
+            displayType={router.location.query.display ?? defaultMetricDisplayType}
             datetime={selection.datetime}
             projects={slugs}
             {...query}
@@ -79,31 +83,6 @@ function MetricsExplorer() {
   );
 }
 
-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: QueryBuilderState) => void;
 };
@@ -135,6 +114,15 @@ type QueryBuilderAction =
 
 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);
@@ -177,18 +165,28 @@ function QueryBuilder({setQuery}: QueryBuilderProps) {
         <PageFilterBar condensed>
           <CompactSelect
             searchable
-            triggerProps={{prefix: 'MRI', size: 'sm'}}
-            options={Object.keys(meta).map(mri => ({
-              label: mri,
-              value: mri,
-            }))}
+            triggerProps={{prefix: t('Metric'), size: 'sm'}}
+            options={Object.values(meta)
+              .filter(metric => (mriMode ? true : metric.mri.includes(':custom/')))
+              .map(metric => ({
+                label: mriMode ? metric.mri : metric.name,
+                value: metric.mri,
+                trailingItems: mriMode ? undefined : (
+                  <Fragment>
+                    <Tag tooltipText={t('Type')}>
+                      {getReadableMetricType(metric.type)}
+                    </Tag>
+                    <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
+                  </Fragment>
+                ),
+              }))}
             value={state.mri}
             onChange={option => {
               dispatch({type: 'mri', value: option.value});
             }}
           />
           <CompactSelect
-            triggerProps={{prefix: 'Operation', size: 'sm'}}
+            triggerProps={{prefix: t('Operation'), size: 'sm'}}
             options={selectedMetric.operations.filter(isAllowedOp).map(op => ({
               label: op,
               value: op,
@@ -198,7 +196,7 @@ function QueryBuilder({setQuery}: QueryBuilderProps) {
           />
           <CompactSelect
             multiple
-            triggerProps={{prefix: 'Group by', size: 'sm'}}
+            triggerProps={{prefix: t('Group by'), size: 'sm'}}
             options={tags.map(tag => ({
               label: tag.key,
               value: tag.key,
@@ -271,7 +269,7 @@ function MetricSearchBar({tags, mri, disabled, onChange}: MetricSearchBarProps)
       supportedTags={supportedTags}
       onClose={handleChange}
       onSearch={handleChange}
-      placeholder="Search for tags"
+      placeholder={t('Filter by tags')}
     />
   );
 }
@@ -298,16 +296,18 @@ type Group = {
 };
 
 type DisplayProps = MetricsDataProps & {
-  displayType: DisplayType;
+  displayType: MetricDisplayType;
 };
 
 function MetricsExplorerDisplayOuter(props?: DisplayProps) {
   if (!props?.mri) {
     return (
       <DisplayWrapper>
-        <EmptyMessage icon={<IconSearch size="xxl" />}>
-          Nothing to show. Choose an MRI to display data!
-        </EmptyMessage>
+        <EmptyMessage
+          icon={<IconSearch size="xxl" />}
+          title={t('Nothing to show!')}
+          description={t('Choose a metric to display data.')}
+        />
       </DisplayWrapper>
     );
   }
@@ -321,7 +321,7 @@ function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps
     return (
       <DisplayWrapper>
         {isLoading && <LoadingIndicator />}
-        {isError && <Alert type="error">Error while fetching metrics data</Alert>}
+        {isError && <Alert type="error">{t('Error while fetching metrics data')}</Alert>}
       </DisplayWrapper>
     );
   }
@@ -330,7 +330,7 @@ function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps
 
   return (
     <DisplayWrapper>
-      {displayType === 'Table' ? (
+      {displayType === MetricDisplayType.TABLE ? (
         <Table data={sorted} />
       ) : (
         <Chart data={sorted} displayType={displayType} />
@@ -344,7 +344,9 @@ function getSeriesName(group: Group, isOnlyGroup = false) {
     return Object.keys(group.series)?.[0] ?? '(none)';
   }
 
-  return Object.values(group.by).join('-') ?? '(none)';
+  return Object.entries(group.by)
+    .map(([key, value]) => `${key}:${String(value).length ? value : t('none')}`)
+    .join(', ');
 }
 
 function sortData(data: MetricsData): MetricsData {
@@ -397,9 +399,11 @@ function normalizeChartTimeParams(data: MetricsData) {
   };
 }
 
-function Chart({data, displayType}: {data: MetricsData; displayType: DisplayType}) {
+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],
@@ -424,29 +428,32 @@ function Chart({data, displayType}: {data: MetricsData; displayType: DisplayType
       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 (
-    <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>
+    <ChartZoom period={period} start={start} end={end} utc={utc}>
+      {zoomRenderProps =>
+        displayType === MetricDisplayType.LINE ? (
+          <LineChart {...chartProps} {...zoomRenderProps} />
+        ) : displayType === MetricDisplayType.AREA ? (
+          <AreaChart {...chartProps} {...zoomRenderProps} />
+        ) : (
+          <BarChart stacked {...chartProps} {...zoomRenderProps} />
+        )
+      }
+    </ChartZoom>
   );
 }