Browse Source

feat(metrics): Display metric details (#68683)

- closes https://github.com/getsentry/sentry/issues/68515
ArthurKnaus 11 months ago
parent
commit
d7faf43d66

+ 15 - 10
static/app/components/comboBox/index.tsx

@@ -46,6 +46,8 @@ interface ComboBoxProps<Value extends string>
   className?: string;
   disabled?: boolean;
   isLoading?: boolean;
+  menuSize?: FormSize;
+  menuWidth?: string;
   size?: FormSize;
   sizeLimit?: number;
   sizeLimitMessage?: string;
@@ -53,12 +55,14 @@ interface ComboBoxProps<Value extends string>
 
 function ComboBox<Value extends string>({
   size = 'md',
+  menuSize,
   className,
   placeholder,
   disabled,
   isLoading,
   sizeLimitMessage,
   menuTrigger = 'focus',
+  menuWidth,
   ...props
 }: ComboBoxProps<Value>) {
   const theme = useTheme();
@@ -96,11 +100,11 @@ function ComboBox<Value extends string>({
 
   // Make popover width constant while it is open
   useEffect(() => {
-    if (listBoxRef.current && state.isOpen) {
-      const listBoxElement = listBoxRef.current;
-      listBoxElement.style.width = `${listBoxElement.offsetWidth + 4}px`;
+    if (popoverRef.current && state.isOpen) {
+      const popoverElement = popoverRef.current;
+      popoverElement.style.width = `${popoverElement.offsetWidth + 4}px`;
       return () => {
-        listBoxElement.style.width = 'max-content';
+        popoverElement.style.width = 'max-content';
       };
     }
     return () => {};
@@ -153,9 +157,9 @@ function ComboBox<Value extends string>({
           zIndex={theme.zIndex?.tooltip}
           visible={state.isOpen}
         >
-          <StyledOverlay ref={popoverRef}>
+          <StyledOverlay ref={popoverRef} width={menuWidth}>
             {isLoading && (
-              <MenuHeader size={size}>
+              <MenuHeader size={menuSize ?? size}>
                 <MenuTitle>{t('Loading...')}</MenuTitle>
                 <MenuHeaderTrailingItems>
                   {isLoading && <StyledLoadingIndicator size={12} mini />}
@@ -170,7 +174,7 @@ function ComboBox<Value extends string>({
                 ref={listBoxRef}
                 listState={state}
                 keyDownHandler={() => true}
-                size={size}
+                size={menuSize ?? size}
                 sizeLimitMessage={sizeLimitMessage}
               />
               <EmptyMessage>No items found</EmptyMessage>
@@ -331,7 +335,7 @@ const SizingDiv = styled('div')<{size?: FormSize}>`
   opacity: 0;
   pointer-events: none;
   z-index: -1;
-  position: absolute;
+  position: fixed;
   white-space: pre;
   font-size: ${p => p.theme.form[p.size ?? 'md'].fontSize};
 `;
@@ -343,16 +347,17 @@ const StyledPositionWrapper = styled(PositionWrapper, {
   display: ${p => (p.visible ? 'block' : 'none')};
 `;
 
-const StyledOverlay = styled(Overlay)`
+const StyledOverlay = styled(Overlay)<{width?: string}>`
   /* Should be a flex container so that when maxHeight is set (to avoid page overflow),
   ListBoxWrap/GridListWrap will also shrink to fit */
   display: flex;
   flex-direction: column;
   overflow: hidden;
-  max-height: 32rem;
   position: absolute;
+  max-height: 32rem;
   min-width: 100%;
   overflow-y: auto;
+  width: ${p => p.width ?? 'auto'};
 `;
 
 export const EmptyMessage = styled('p')`

+ 22 - 13
static/app/utils/metrics/useMetricsTags.tsx

@@ -1,4 +1,4 @@
-import type {MRI, PageFilters} from 'sentry/types';
+import type {MRI, Organization, PageFilters} from 'sentry/types';
 import {getUseCaseFromMRI} from 'sentry/utils/metrics/mri';
 import type {MetricTag} from 'sentry/utils/metrics/types';
 import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
@@ -7,15 +7,12 @@ import useOrganization from 'sentry/utils/useOrganization';
 
 import {getMetaDateTimeParams} from './index';
 
-export function useMetricsTags(
+export function getMetricsTagsQueryKey(
+  organization: Organization,
   mri: MRI | undefined,
-  pageFilters: Partial<PageFilters>,
-  filterBlockedTags = true,
-  blockedTags?: string[]
+  pageFilters: Partial<PageFilters>
 ) {
-  const {slug} = useOrganization();
   const useCase = getUseCaseFromMRI(mri) ?? 'custom';
-
   const queryParams = pageFilters.projects?.length
     ? {
         metric: mri,
@@ -29,13 +26,25 @@ export function useMetricsTags(
         ...getMetaDateTimeParams(pageFilters.datetime),
       };
 
+  return [
+    `/organizations/${organization.slug}/metrics/tags/`,
+    {
+      query: queryParams,
+    },
+  ] as const;
+}
+
+export function useMetricsTags(
+  mri: MRI | undefined,
+  pageFilters: Partial<PageFilters>,
+  filterBlockedTags = true,
+  blockedTags?: string[]
+) {
+  const organization = useOrganization();
+  const useCase = getUseCaseFromMRI(mri) ?? 'custom';
+
   const tagsQuery = useApiQuery<MetricTag[]>(
-    [
-      `/organizations/${slug}/metrics/tags/`,
-      {
-        query: queryParams,
-      },
-    ],
+    getMetricsTagsQueryKey(organization, mri, pageFilters),
     {
       enabled: !!mri,
       staleTime: Infinity,

+ 155 - 0
static/app/views/metrics/metricListItemDetails.tsx

@@ -0,0 +1,155 @@
+import {Fragment, startTransition, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {MetricMeta, Project} from 'sentry/types';
+import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
+import {formatMRI} from 'sentry/utils/metrics/mri';
+import {
+  getMetricsTagsQueryKey,
+  useMetricsTags,
+} from 'sentry/utils/metrics/useMetricsTags';
+import {useQueryClient} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+
+const MAX_PROJECTS_TO_SHOW = 3;
+const MAX_TAGS_TO_SHOW = 5;
+
+export function MetricListItemDetails({
+  metric,
+  selectedProjects,
+}: {
+  metric: MetricMeta;
+  selectedProjects: Project[];
+}) {
+  const organization = useOrganization();
+  const queryClient = useQueryClient();
+
+  const projectIds = useMemo(
+    () => selectedProjects.map(project => parseInt(project.id, 10)),
+    [selectedProjects]
+  );
+
+  const [isQueryEnabled, setIsQueryEnabled] = useState(() => {
+    // We only wnat to disable the query if there is no data in the cache
+    const queryKey = getMetricsTagsQueryKey(organization, metric.mri, {
+      projects: projectIds,
+    });
+    const data = queryClient.getQueryData(queryKey);
+    return !!data;
+  });
+
+  const {data: tagsData = [], isLoading: tagsIsLoading} = useMetricsTags(
+    // TODO: improve useMetricsTag interface
+    isQueryEnabled ? metric.mri : undefined,
+    {
+      projects: projectIds,
+    }
+  );
+
+  useEffect(() => {
+    // Start querying tags after a short delay to avoid querying
+    // for every metric if a user quickly hovers over them
+    const timeout = setTimeout(() => {
+      startTransition(() => setIsQueryEnabled(true));
+    }, 200);
+    return () => clearTimeout(timeout);
+  }, []);
+
+  const metricProjects = selectedProjects.filter(project =>
+    metric.projectIds.includes(parseInt(project.id, 10))
+  );
+
+  const truncatedProjects = metricProjects.slice(0, MAX_PROJECTS_TO_SHOW);
+  const truncatedTags = tagsData.slice(0, MAX_TAGS_TO_SHOW);
+
+  return (
+    <DetailsWrapper>
+      <MetricName>
+        {/* Add zero width spaces at delimiter characters for nice word breaks */}
+        {formatMRI(metric.mri).replaceAll(/([\.\/-_])/g, '\u200b$1')}
+      </MetricName>
+      <DetailsGrid>
+        <DetailsLabel>Project</DetailsLabel>
+        <DetailsValue>
+          {truncatedProjects.map(project => (
+            <ProjectBadge
+              project={project}
+              key={project.slug}
+              avatarSize={12}
+              disableLink
+            />
+          ))}
+          {metricProjects.length > MAX_PROJECTS_TO_SHOW && (
+            <span>{t('+%d more', metricProjects.length - MAX_PROJECTS_TO_SHOW)}</span>
+          )}
+        </DetailsValue>
+        <DetailsLabel>Type</DetailsLabel>
+        <DetailsValue>{getReadableMetricType(metric.type)}</DetailsValue>
+        <DetailsLabel>Unit</DetailsLabel>
+        <DetailsValue>{metric.unit}</DetailsValue>
+        <DetailsLabel>Tags</DetailsLabel>
+        <DetailsValue>
+          {tagsIsLoading || !isQueryEnabled ? (
+            <StyledLoadingIndicator mini size={12} />
+          ) : truncatedTags.length === 0 ? (
+            t('(None)')
+          ) : (
+            <Fragment>
+              {truncatedTags.map(tag => tag.key).join(', ')}
+              {tagsData.length > MAX_TAGS_TO_SHOW && (
+                <div>{t('+%d more', tagsData.length - MAX_TAGS_TO_SHOW)}</div>
+              )}
+            </Fragment>
+          )}
+        </DetailsValue>
+      </DetailsGrid>
+    </DetailsWrapper>
+  );
+}
+
+const DetailsWrapper = styled('div')`
+  width: 300px;
+  line-height: 1.4;
+`;
+
+const MetricName = styled('div')`
+  padding: ${space(0.75)} ${space(1.5)};
+  word-break: break-word;
+`;
+
+const DetailsGrid = styled('div')`
+  display: grid;
+  grid-template-columns: max-content 1fr;
+
+  & > div:nth-child(4n + 1),
+  & > div:nth-child(4n + 2) {
+    background-color: ${p => p.theme.backgroundSecondary};
+  }
+`;
+
+const StyledLoadingIndicator = styled(LoadingIndicator)`
+  && {
+    margin: ${space(0.75)} 0 0;
+    height: 12px;
+    width: 12px;
+  }
+`;
+
+const DetailsLabel = styled('div')`
+  color: ${p => p.theme.subText};
+  padding: ${space(0.75)} ${space(1)} ${space(0.75)} ${space(1.5)};
+  border-top-left-radius: ${p => p.theme.borderRadius};
+  border-bottom-left-radius: ${p => p.theme.borderRadius};
+`;
+
+const DetailsValue = styled('div')`
+  white-space: pre-wrap;
+  padding: ${space(0.75)} ${space(1.5)} ${space(0.75)} ${space(1)};
+  border-top-right-radius: ${p => p.theme.borderRadius};
+  border-bottom-right-radius: ${p => p.theme.borderRadius};
+  min-width: 0;
+`;

+ 42 - 17
static/app/views/metrics/queryBuilder.tsx

@@ -6,7 +6,6 @@ import {ComboBox} from 'sentry/components/comboBox';
 import type {ComboBoxOption} from 'sentry/components/comboBox/types';
 import type {SelectOption} from 'sentry/components/compactSelect';
 import {CompactSelect} from 'sentry/components/compactSelect';
-import {Tag} from 'sentry/components/tag';
 import {IconLightning, IconReleases} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -32,6 +31,8 @@ import {middleEllipsis} from 'sentry/utils/middleEllipsis';
 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 {MetricListItemDetails} from 'sentry/views/metrics/metricListItemDetails';
 import {MetricSearchBar} from 'sentry/views/metrics/metricSearchBar';
 
 type QueryBuilderProps = {
@@ -71,12 +72,14 @@ function useMriMode() {
 
 export const QueryBuilder = memo(function QueryBuilder({
   metricsQuery,
-  projects,
+  projects: projectIds,
   onChange,
 }: QueryBuilderProps) {
   const organization = useOrganization();
   const pageFilters = usePageFilters();
   const breakpoints = useBreakpoints();
+  const {projects} = useProjects();
+
   const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(pageFilters.selection);
   const mriMode = useMriMode();
 
@@ -85,10 +88,22 @@ export const QueryBuilder = memo(function QueryBuilder({
   const {data: tagsData = [], isLoading: tagsIsLoading} = useMetricsTags(
     metricsQuery.mri,
     {
-      projects,
+      projects: projectIds,
     }
   );
 
+  const selectedProjects = useMemo(
+    () =>
+      projects.filter(project =>
+        projectIds[0] === -1
+          ? true
+          : projectIds.length === 0
+            ? project.isMember
+            : projectIds.includes(parseInt(project.id, 10))
+      ),
+    [projectIds, projects]
+  );
+
   const tags = useMemo(() => {
     return uniqBy(tagsData, 'key');
   }, [tagsData]);
@@ -108,7 +123,9 @@ export const QueryBuilder = memo(function QueryBuilder({
           type: parsedMri.type,
           unit: parsedMri.unit,
           operations: [],
-        },
+          projectIds: [],
+          blockingStatus: [],
+        } satisfies MetricMeta,
         ...result,
       ];
     }
@@ -182,21 +199,26 @@ export const QueryBuilder = memo(function QueryBuilder({
   const mriOptions = useMemo(
     () =>
       displayedMetrics.map<ComboBoxOption<MRI>>(metric => ({
-        label: mriMode ? metric.mri : formatMRI(metric.mri),
+        label: mriMode
+          ? metric.mri
+          : middleEllipsis(formatMRI(metric.mri) ?? '', 55, /\.|-|_/),
         // enable search by mri, name, unit (millisecond), type (c:), and readable type (counter)
         textValue: `${metric.mri}${getReadableMetricType(metric.type)}`,
         value: metric.mri,
-        trailingItems: mriMode ? undefined : (
-          <Fragment>
-            <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
-            <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
-          </Fragment>
-        ),
+        details:
+          metric.projectIds.length > 0 ? (
+            <MetricListItemDetails metric={metric} selectedProjects={selectedProjects} />
+          ) : null,
+        showDetailsInOverlay: true,
+        trailingItems:
+          mriMode || parseMRI(metric.mri)?.useCase !== 'custom' ? undefined : (
+            <CustomMetricInfoText>{t('Custom')}</CustomMetricInfoText>
+          ),
       })),
-    [displayedMetrics, mriMode]
+    [displayedMetrics, mriMode, selectedProjects]
   );
 
-  const projectIdStrings = useMemo(() => projects.map(String), [projects]);
+  const projectIdStrings = useMemo(() => projectIds.map(String), [projectIds]);
 
   return (
     <QueryBuilderWrapper>
@@ -207,10 +229,12 @@ export const QueryBuilder = memo(function QueryBuilder({
             placeholder={t('Select a metric')}
             sizeLimit={100}
             size="md"
+            menuSize="sm"
             isLoading={isMetaLoading}
             options={mriOptions}
             value={metricsQuery.mri}
             onChange={handleMRIChange}
+            menuWidth="400px"
           />
         ) : (
           <MetricSelect
@@ -225,7 +249,6 @@ export const QueryBuilder = memo(function QueryBuilder({
             options={mriOptions}
             value={metricsQuery.mri}
             onChange={handleMRIChange}
-            shouldUseVirtualFocus
           />
         )}
         <FlexBlock>
@@ -277,6 +300,10 @@ export const QueryBuilder = memo(function QueryBuilder({
   );
 });
 
+const CustomMetricInfoText = styled('span')`
+  color: ${p => p.theme.subText};
+`;
+
 const QueryBuilderWrapper = styled('div')`
   display: flex;
   flex-grow: 1;
@@ -292,9 +319,7 @@ const FlexBlock = styled('div')`
 
 const MetricComboBox = styled(ComboBox)`
   min-width: 200px;
-  & > button {
-    width: 100%;
-  }
+  max-width: min(500px, 100%);
 `;
 const MetricSelect = styled(CompactSelect)`
   min-width: 200px;