Repository: getsentry/sentry
Pull request: feat(ddm): Change ddm to a metric explorer experience
Move query builder controls to top of page.
Add identification symbols to queries.

<img width="1710" alt="Screenshot 2024-01-10 at 17 08 02" src="https://github.com/getsentry/sentry/assets/7033940/a45437a9-0e22-4f31-9df8-a7899784c576">

- closes https://github.com/getsentry/sentry/issues/62735
- closes https://github.com/getsentry/sentry/issues/62736
- closes https://github.com/getsentry/sentry/issues/62930

Problem statement:
Have just one full-width widget and move controls (Metric, Op, Group, Filter) out of it and put them under page filters on one line, with an option to add another query (which would add a second chart).


Leave the display dropdown in the chart next to the three dots or move it inside the three dots.


Consider different default operation
By default, we offer count on transaction.duration. We should consider changing it to one of the percentiles or avg.

Hints from issue discussion:

Commit message: fix(tests): Add missing mock response

Patch to review:
diff --git a/static/app/utils/metrics/index.tsx b/static/app/utils/metrics/index.tsx
index e2828adb5cc43a..255096ed7c4f09 100644
--- a/static/app/utils/metrics/index.tsx
+++ b/static/app/utils/metrics/index.tsx
@@ -66,7 +66,30 @@ export enum MetricDisplayType {
   TABLE = 'table',
 }
 
-export const defaultMetricDisplayType = MetricDisplayType.LINE;
+export const metricDisplayTypeOptions = [
+  {
+    value: MetricDisplayType.LINE,
+    label: t('Line'),
+  },
+  {
+    value: MetricDisplayType.AREA,
+    label: t('Area'),
+  },
+  {
+    value: MetricDisplayType.BAR,
+    label: t('Bar'),
+  },
+];
+
+export function getDefaultMetricDisplayType(
+  mri: MetricsQuery['mri'],
+  op: MetricsQuery['op']
+): MetricDisplayType {
+  if (mri?.startsWith('c') || op === 'count') {
+    return MetricDisplayType.BAR;
+  }
+  return MetricDisplayType.LINE;
+}
 
 export const getMetricDisplayType = (displayType: unknown): MetricDisplayType => {
   if (
@@ -629,7 +652,11 @@ function swapObjectKeys(obj: Record<string, unknown> | undefined, newKeys: strin
 }
 
 export function stringifyMetricWidget(metricWidget: MetricsQuerySubject): string {
-  const {mri, op, query, groupBy} = metricWidget;
+  const {mri, op, query, groupBy, title} = metricWidget;
+
+  if (title) {
+    return title;
+  }
 
   if (!op) {
     return '';
diff --git a/static/app/utils/metrics/useIncrementQueryMetric.tsx b/static/app/utils/metrics/useIncrementQueryMetric.tsx
new file mode 100644
index 00000000000000..ac1b7460eea637
--- /dev/null
+++ b/static/app/utils/metrics/useIncrementQueryMetric.tsx
@@ -0,0 +1,34 @@
+import * as Sentry from '@sentry/react';
+
+import {MRI} from 'sentry/types';
+import {
+  getDefaultMetricDisplayType,
+  getReadableMetricType,
+  MetricDisplayType,
+} from 'sentry/utils/metrics';
+import {parseMRI} from 'sentry/utils/metrics/mri';
+
+interface Options {
+  displayType: MetricDisplayType;
+  mri: MRI;
+  groupBy?: string[];
+  op?: string;
+  query?: string;
+}
+
+export const useIncrementQueryMetric = (options: Options) => {
+  return (metricName: string, values: Partial<Options>) => {
+    const mergedValues = {...options, ...values};
+    Sentry.metrics.increment(metricName, 1, {
+      tags: {
+        display:
+          mergedValues.displayType ??
+          getDefaultMetricDisplayType(mergedValues.mri, mergedValues.op),
+        type: getReadableMetricType(parseMRI(mergedValues.mri)?.type),
+        operation: mergedValues.op,
+        isGrouped: !!mergedValues.groupBy?.length,
+        isFiltered: !!mergedValues.query,
+      },
+    });
+  };
+};
diff --git a/static/app/views/app/index.spec.tsx b/static/app/views/app/index.spec.tsx
index 229489295c5967..d5e7215868af5e 100644
--- a/static/app/views/app/index.spec.tsx
+++ b/static/app/views/app/index.spec.tsx
@@ -25,6 +25,11 @@ describe('App', function () {
       body: [],
     });
 
+    MockApiClient.addMockResponse({
+      url: '/internal/health/',
+      body: [],
+    });
+
     MockApiClient.addMockResponse({
       url: '/internal/options/?query=is:required',
       body: InstallWizardFixture(),
@@ -67,7 +72,10 @@ describe('App', function () {
   });
 
   it('renders PartnershipAgreement', async function () {
-    ConfigStore.set('partnershipAgreementPrompt', {partnerDisplayName: 'Foo', agreements:['standard', 'partner_presence']});
+    ConfigStore.set('partnershipAgreementPrompt', {
+      partnerDisplayName: 'Foo',
+      agreements: ['standard', 'partner_presence'],
+    });
     render(
       <App {...routerProps}>
         <div>placeholder content</div>
@@ -78,7 +86,9 @@ describe('App', function () {
       await screen.findByText(/This organization is created in partnership with Foo/)
     ).toBeInTheDocument();
     expect(
-      await screen.findByText(/and are aware of the partner's presence in the organization as a manager./)
+      await screen.findByText(
+        /and are aware of the partner's presence in the organization as a manager./
+      )
     ).toBeInTheDocument();
     expect(screen.queryByText('placeholder content')).not.toBeInTheDocument();
   });
@@ -92,7 +102,9 @@ describe('App', function () {
     );
 
     expect(screen.getByText('placeholder content')).toBeInTheDocument();
-    expect(await screen.queryByText(/This organization is created in partnership/)).not.toBeInTheDocument();
+    expect(
+      await screen.queryByText(/This organization is created in partnership/)
+    ).not.toBeInTheDocument();
   });
 
   it('renders InstallWizard for self-hosted', async function () {
@@ -147,7 +159,11 @@ describe('App', function () {
       body: {
         healthy: false,
         problems: [
-          {id: 'abc123', message: 'Celery workers have not checked in', severity: 'critical'},
+          {
+            id: 'abc123',
+            message: 'Celery workers have not checked in',
+            severity: 'critical',
+          },
         ],
       },
     });
@@ -162,6 +178,8 @@ describe('App', function () {
     ConfigStore.config.isSelfHosted = restore;
 
     expect(getMock).toHaveBeenCalled();
-    expect(await screen.findByText(/Celery workers have not checked in/)).toBeInTheDocument();
+    expect(
+      await screen.findByText(/Celery workers have not checked in/)
+    ).toBeInTheDocument();
   });
 });
diff --git a/static/app/views/ddm/context.tsx b/static/app/views/ddm/context.tsx
index 785478d3dc264e..6dbe072baa6cf4 100644
--- a/static/app/views/ddm/context.tsx
+++ b/static/app/views/ddm/context.tsx
@@ -10,8 +10,8 @@ import * as Sentry from '@sentry/react';
 
 import {MRI} from 'sentry/types';
 import {
-  defaultMetricDisplayType,
   getAbsoluteDateTimeRange,
+  getDefaultMetricDisplayType,
   MetricDisplayType,
   MetricWidgetQueryParams,
   useInstantRef,
@@ -63,7 +63,7 @@ export function useDDMContext() {
 
 const emptyWidget: MetricWidgetQueryParams = {
   mri: 'd:transactions/duration@millisecond' satisfies MRI,
-  op: 'count',
+  op: 'avg',
   query: '',
   groupBy: [],
   sort: DEFAULT_SORT_STATE,
@@ -87,7 +87,8 @@ export function useMetricWidgets() {
           op: widget.op,
           query: widget.query,
           groupBy: decodeList(widget.groupBy),
-          displayType: widget.displayType ?? defaultMetricDisplayType,
+          displayType:
+            widget.displayType ?? getDefaultMetricDisplayType(widget.mri, widget.op),
           focusedSeries: widget.focusedSeries,
           showSummaryTable: widget.showSummaryTable ?? true, // temporary default
           powerUserMode: widget.powerUserMode,
diff --git a/static/app/views/ddm/contextMenu.tsx b/static/app/views/ddm/contextMenu.tsx
index ea4259cb99128a..c23d052d2272f4 100644
--- a/static/app/views/ddm/contextMenu.tsx
+++ b/static/app/views/ddm/contextMenu.tsx
@@ -1,22 +1,18 @@
 import {useMemo} from 'react';
-import styled from '@emotion/styled';
 import * as Sentry from '@sentry/react';
 
 import {openAddToDashboardModal, openModal} from 'sentry/actionCreators/modal';
 import {navigateTo} from 'sentry/actionCreators/navigation';
-import {Button} from 'sentry/components/button';
 import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
 import {
   IconCopy,
   IconDashboard,
   IconDelete,
-  IconEdit,
   IconEllipsis,
   IconSettings,
   IconSiren,
 } from 'sentry/icons';
 import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
 import {Organization} from 'sentry/types';
 import {
   isCustomMeasurement,
@@ -39,18 +35,14 @@ import {OrganizationContext} from 'sentry/views/organizationContext';
 
 type ContextMenuProps = {
   displayType: MetricDisplayType;
-  isEdit: boolean;
   metricsQuery: MetricsQuery;
-  onEdit: () => void;
   widgetIndex: number;
 };
 
-export function MetricWidgetContextMenu({
+export function MetricQueryContextMenu({
   metricsQuery,
   displayType,
   widgetIndex,
-  onEdit,
-  isEdit,
 }: ContextMenuProps) {
   const organization = useOrganization();
   const router = useRouter();
@@ -138,38 +130,19 @@ export function MetricWidgetContextMenu({
   }
 
   return (
-    <Wrapper>
-      {!isEdit && (
-        <Button
-          onClick={onEdit}
-          borderless
-          size="xs"
-          aria-label={t('Edit widget')}
-          icon={<IconEdit />}
-        />
-      )}
-
-      <DropdownMenu
-        items={items}
-        triggerProps={{
-          'aria-label': t('Widget actions'),
-          size: 'xs',
-          borderless: true,
-          showChevron: false,
-          icon: <IconEllipsis direction="down" size="sm" />,
-        }}
-        position="bottom-end"
-      />
-    </Wrapper>
+    <DropdownMenu
+      items={items}
+      triggerProps={{
+        'aria-label': t('Widget actions'),
+        size: 'md',
+        showChevron: false,
+        icon: <IconEllipsis direction="down" size="sm" />,
+      }}
+      position="bottom-end"
+    />
   );
 }
 
-const Wrapper = styled('div')`
-  display: flex;
-  gap: ${space(1)};
-  margin: ${space(1)} ${space(0.5)} 0 0;
-`;
-
 export function useCreateAlert(organization: Organization, metricsQuery: MetricsQuery) {
   return useMemo(() => {
     if (
diff --git a/static/app/views/ddm/layout.tsx b/static/app/views/ddm/layout.tsx
index cd8ef798be7f4e..1e3b04f6c9cc40 100644
--- a/static/app/views/ddm/layout.tsx
+++ b/static/app/views/ddm/layout.tsx
@@ -26,6 +26,7 @@ import useOrganization from 'sentry/utils/useOrganization';
 import {useDDMContext} from 'sentry/views/ddm/context';
 import {useDashboardImport} from 'sentry/views/ddm/dashboardImportModal';
 import {useMetricsOnboardingSidebar} from 'sentry/views/ddm/ddmOnboarding/useMetricsOnboardingSidebar';
+import {Queries} from 'sentry/views/ddm/queries';
 import {MetricScratchpad} from 'sentry/views/ddm/scratchpad';
 import ShareButton from 'sentry/views/ddm/shareButton';
 import {WidgetDetails} from 'sentry/views/ddm/widgetDetails';
@@ -105,6 +106,7 @@ export const DDMLayout = memo(() => {
             <LoadingIndicator />
           ) : hasMetrics ? (
             <Fragment>
+              <Queries />
               <MetricScratchpad />
               <WidgetDetails />
             </Fragment>
diff --git a/static/app/views/ddm/queries.tsx b/static/app/views/ddm/queries.tsx
new file mode 100644
index 00000000000000..38416628070a6c
--- /dev/null
+++ b/static/app/views/ddm/queries.tsx
@@ -0,0 +1,102 @@
+import {Fragment, useCallback, useLayoutEffect} from 'react';
+import styled from '@emotion/styled';
+import * as Sentry from '@sentry/react';
+import * as echarts from 'echarts/core';
+
+import {Button} from 'sentry/components/button';
+import {IconAdd} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {MetricWidgetQueryParams} from 'sentry/utils/metrics';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {DDM_CHART_GROUP} from 'sentry/views/ddm/constants';
+import {useDDMContext} from 'sentry/views/ddm/context';
+import {MetricQueryContextMenu} from 'sentry/views/ddm/contextMenu';
+import {QueryBuilder} from 'sentry/views/ddm/queryBuilder';
+import {QuerySymbol} from 'sentry/views/ddm/querySymbol';
+
+export function Queries() {
+  const organization = useOrganization();
+  const {widgets, updateWidget, addWidget} = useDDMContext();
+  const {selection} = usePageFilters();
+
+  const hasEmptyWidget = widgets.length === 0 || widgets.some(widget => !widget.mri);
+
+  // Make sure all charts are connected to the same group whenever the widgets definition changes
+  useLayoutEffect(() => {
+    echarts.connect(DDM_CHART_GROUP);
+  }, [widgets]);
+
+  const handleChange = useCallback(
+    (index: number, widget: Partial<MetricWidgetQueryParams>) => {
+      updateWidget(index, widget);
+    },
+    [updateWidget]
+  );
+
+  return (
+    <Wrapper>
+      {widgets.map((widget, index) => (
+        <Fragment key={index}>
+          <StyledQuerySymbol index={index} />
+          <QueryBuilder
+            onChange={data => handleChange(index, data)}
+            metricsQuery={{
+              mri: widget.mri,
+              op: widget.op,
+              groupBy: widget.groupBy,
+              title: widget.title,
+            }}
+            displayType={widget.displayType}
+            isEdit
+            projects={selection.projects}
+          />
+          <MetricQueryContextMenu
+            displayType={widget.displayType}
+            widgetIndex={index}
+            metricsQuery={{
+              mri: widget.mri,
+              query: widget.query,
+              op: widget.op,
+              groupBy: widget.groupBy,
+              projects: selection.projects,
+              datetime: selection.datetime,
+              environments: selection.environments,
+              title: widget.title,
+            }}
+          />
+        </Fragment>
+      ))}
+      {/* placeholder for first grid column */}
+      <div />
+      <div>
+        <Button
+          disabled={hasEmptyWidget}
+          icon={<IconAdd size="xs" isCircled />}
+          onClick={() => {
+            trackAnalytics('ddm.widget.add', {
+              organization,
+            });
+            Sentry.metrics.increment('ddm.widget.add');
+            addWidget();
+          }}
+        >
+          {t('Add Query')}
+        </Button>
+      </div>
+    </Wrapper>
+  );
+}
+
+const StyledQuerySymbol = styled(QuerySymbol)`
+  margin-top: 10px;
+`;
+
+const Wrapper = styled('div')`
+  padding-bottom: ${space(2)};
+  display: grid;
+  grid-template-columns: min-content 1fr max-content;
+  gap: ${space(1)};
+`;
diff --git a/static/app/views/ddm/queryBuilder.tsx b/static/app/views/ddm/queryBuilder.tsx
index a329bce2ae4948..e0b6d78ad01d67 100644
--- a/static/app/views/ddm/queryBuilder.tsx
+++ b/static/app/views/ddm/queryBuilder.tsx
@@ -1,34 +1,30 @@
 import {Fragment, memo, useEffect, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
-import * as Sentry from '@sentry/react';
 
 import {navigateTo} from 'sentry/actionCreators/navigation';
 import {Button} from 'sentry/components/button';
-import {HeaderTitle} from 'sentry/components/charts/styles';
-import {CompactSelect} from 'sentry/components/compactSelect';
-import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import {CompactSelect, SelectOption} from 'sentry/components/compactSelect';
 import Tag from 'sentry/components/tag';
-import TextOverflow from 'sentry/components/textOverflow';
 import {IconLightning, IconReleases, IconSettings} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import {MetricMeta, MRI} from 'sentry/types';
+import {MetricMeta, MetricsOperation, MRI} from 'sentry/types';
 import {
-  defaultMetricDisplayType,
+  getDefaultMetricDisplayType,
   getReadableMetricType,
   isAllowedOp,
   isCustomMetric,
   isMeasurement,
   isTransactionDuration,
   MetricDisplayType,
-  MetricsQuery,
   MetricsQuerySubject,
   MetricWidgetQueryParams,
-  stringifyMetricWidget,
 } from 'sentry/utils/metrics';
-import {formatMRI, parseMRI} from 'sentry/utils/metrics/mri';
+import {formatMRI} from 'sentry/utils/metrics/mri';
+import {useIncrementQueryMetric} from 'sentry/utils/metrics/useIncrementQueryMetric';
 import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta';
 import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
+import {middleEllipsis} from 'sentry/utils/middleEllipsis';
 import useKeyPress from 'sentry/utils/useKeyPress';
 import useRouter from 'sentry/utils/useRouter';
 import {MetricSearchBar} from 'sentry/views/ddm/metricSearchBar';
@@ -36,7 +32,6 @@ import {MetricSearchBar} from 'sentry/views/ddm/metricSearchBar';
 type QueryBuilderProps = {
   displayType: MetricDisplayType;
   isEdit: boolean;
-  // TODO(ddm): move display type out of the query builder
   metricsQuery: MetricsQuerySubject;
   onChange: (data: Partial<MetricWidgetQueryParams>) => void;
   projects: number[];
@@ -46,8 +41,8 @@ type QueryBuilderProps = {
 const isShownByDefault = (metric: MetricMeta) =>
   isMeasurement(metric) || isCustomMetric(metric) || isTransactionDuration(metric);
 
-function stopPropagation(e: React.MouseEvent) {
-  e.stopPropagation();
+function getOpsForMRI(mri: MRI, meta: MetricMeta[]) {
+  return meta.find(metric => metric.mri === mri)?.operations.filter(isAllowedOp) ?? [];
 }
 
 export const QueryBuilder = memo(function QueryBuilder({
@@ -56,7 +51,6 @@ export const QueryBuilder = memo(function QueryBuilder({
   displayType,
   powerUserMode,
   onChange,
-  isEdit,
 }: QueryBuilderProps) {
   const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(projects);
   const router = useRouter();
@@ -98,93 +92,104 @@ export const QueryBuilder = memo(function QueryBuilder({
     }
   }, [isMetaLoading, displayedMetrics, metricsQuery.mri, onChange]);
 
-  const stringifiedMetricWidget = stringifyMetricWidget(metricsQuery);
+  const incrementQueryMetric = useIncrementQueryMetric({
+    displayType,
+    op: metricsQuery.op,
+    groupBy: metricsQuery.groupBy,
+    query: metricsQuery.query,
+    mri: metricsQuery.mri,
+  });
 
-  const readableType = getReadableMetricType(parseMRI(metricsQuery.mri)?.type);
+  const handleMRIChange = ({value}: SelectOption<MRI>) => {
+    const availableOps = getOpsForMRI(value, meta);
+    const selectedOp = availableOps.includes((metricsQuery.op ?? '') as MetricsOperation)
+      ? metricsQuery.op
+      : availableOps?.[0];
 
-  if (!isEdit) {
-    return (
-      <QueryBuilderWrapper>
-        <WidgetTitle>
-          <TextOverflow>{metricsQuery.title || stringifiedMetricWidget}</TextOverflow>
-        </WidgetTitle>
-      </QueryBuilderWrapper>
-    );
-  }
+    const queryChanges = {
+      mri: value,
+      op: selectedOp,
+      groupBy: undefined,
+      displayType: getDefaultMetricDisplayType(value, selectedOp),
+    };
 
-  return (
-    <QueryBuilderWrapper>
-      <QueryBuilderRow>
-        <WrapPageFilterBar>
-          <CompactSelect
-            searchable
-            sizeLimit={100}
-            triggerProps={{prefix: t('Metric'), size: 'sm'}}
-            options={displayedMetrics.map(metric => ({
-              label: mriMode ? metric.mri : formatMRI(metric.mri),
-              // 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
-                : ({isFocused}) => (
-                    <Fragment>
-                      {isFocused && isCustomMetric({mri: metric.mri}) && (
-                        <Button
-                          borderless
-                          size="zero"
-                          icon={<IconSettings />}
-                          aria-label={t('Metric Settings')}
-                          onPointerDown={() => {
-                            // not using onClick to beat the dropdown listener
-                            navigateTo(
-                              `/settings/projects/:projectId/metrics/${encodeURIComponent(
-                                metric.mri
-                              )}`,
-                              router
-                            );
-                          }}
-                        />
-                      )}
+    incrementQueryMetric('ddm.widget.metric', queryChanges);
+    onChange({
+      ...queryChanges,
+      focusedSeries: undefined,
+    });
+  };
 
-                      <Tag tooltipText={t('Type')}>
-                        {getReadableMetricType(metric.type)}
-                      </Tag>
-                      <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
-                    </Fragment>
-                  ),
-            }))}
-            value={metricsQuery.mri}
-            onChange={option => {
-              const availableOps =
-                meta
-                  .find(metric => metric.mri === option.value)
-                  ?.operations.filter(isAllowedOp) ?? [];
+  const handleOpChange = ({value}: SelectOption<MetricsOperation>) => {
+    incrementQueryMetric('ddm.widget.operation', {op: value});
+    onChange({
+      op: value,
+    });
+  };
 
-              // @ts-expect-error .op is an operation
-              const selectedOp = availableOps.includes(metricsQuery.op ?? '')
-                ? metricsQuery.op
-                : availableOps?.[0];
-              Sentry.metrics.increment('ddm.widget.metric', 1, {
-                tags: {
-                  display: displayType ?? defaultMetricDisplayType,
-                  type: readableType,
-                  operation: selectedOp,
-                  isGrouped: !!metricsQuery.groupBy?.length,
-                  isFiltered: !!metricsQuery.query,
-                },
-              });
-              onChange({
-                mri: option.value,
-                op: selectedOp,
-                groupBy: undefined,
-                focusedSeries: undefined,
-                displayType: getWidgetDisplayType(option.value, selectedOp),
-              });
-            }}
-          />
+  const handleGroupByChange = (options: SelectOption<string>[]) => {
+    incrementQueryMetric('ddm.widget.group', {
+      groupBy: options.map(o => o.value),
+    });
+    onChange({
+      groupBy: options.map(o => o.value),
+      focusedSeries: undefined,
+    });
+  };
+
+  const mriOptions = useMemo(
+    () =>
+      displayedMetrics.map<SelectOption<MRI>>(metric => ({
+        label: mriMode ? metric.mri : formatMRI(metric.mri),
+        // 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
+          : ({isFocused}) => (
+              <Fragment>
+                {isFocused && isCustomMetric({mri: metric.mri}) && (
+                  <Button
+                    borderless
+                    size="zero"
+                    icon={<IconSettings />}
+                    aria-label={t('Metric Settings')}
+                    onPointerDown={() => {
+                      // not using onClick to beat the dropdown listener
+                      navigateTo(
+                        `/settings/projects/:projectId/metrics/${encodeURIComponent(
+                          metric.mri
+                        )}`,
+                        router
+                      );
+                    }}
+                  />
+                )}
+
+                <Tag tooltipText={t('Type')}>{getReadableMetricType(metric.type)}</Tag>
+                <Tag tooltipText={t('Unit')}>{metric.unit}</Tag>
+              </Fragment>
+            ),
+      })),
+    [displayedMetrics, mriMode, router]
+  );
+
+  return (
+    <QueryBuilderWrapper>
+      <FlexBlock>
+        <CompactSelect
+          searchable
+          sizeLimit={100}
+          size="md"
+          triggerLabel={middleEllipsis(formatMRI(metricsQuery.mri) ?? '', 35, /\.|-|_/)}
+          options={mriOptions}
+          value={metricsQuery.mri}
+          onChange={handleMRIChange}
+        />
+        <FlexBlock>
           <CompactSelect
-            triggerProps={{prefix: t('Op'), size: 'sm'}}
+            size="md"
+            triggerProps={{prefix: t('Op')}}
             options={
               selectedMeta?.operations.filter(isAllowedOp).map(op => ({
                 label: op,
@@ -192,25 +197,13 @@ export const QueryBuilder = memo(function QueryBuilder({
               })) ?? []
             }
             disabled={!metricsQuery.mri}
-            value={metricsQuery.op}
-            onChange={option => {
-              Sentry.metrics.increment('ddm.widget.operation', 1, {
-                tags: {
-                  display: displayType ?? defaultMetricDisplayType,
-                  type: readableType,
-                  operation: option.value,
-                  isGrouped: !!metricsQuery.groupBy?.length,
-                  isFiltered: !!metricsQuery.query,
-                },
-              });
-              onChange({
-                op: option.value,
-              });
-            }}
+            value={metricsQuery.op as MetricsOperation}
+            onChange={handleOpChange}
           />
           <CompactSelect
             multiple
-            triggerProps={{prefix: t('Group by'), size: 'sm'}}
+            size="md"
+            triggerProps={{prefix: t('Group by')}}
             options={tags.map(tag => ({
               label: tag.key,
               value: tag.key,
@@ -223,109 +216,41 @@ export const QueryBuilder = memo(function QueryBuilder({
             }))}
             disabled={!metricsQuery.mri}
             value={metricsQuery.groupBy}
-            onChange={options => {
-              Sentry.metrics.increment('ddm.widget.group', 1, {
-                tags: {
-                  display: displayType ?? defaultMetricDisplayType,
-                  type: readableType,
-                  operation: metricsQuery.op,
-                  isGrouped: !!metricsQuery.groupBy?.length,
-                  isFiltered: !!metricsQuery.query,
-                },
-              });
-              onChange({
-                groupBy: options.map(o => o.value),
-                focusedSeries: undefined,
-              });
-            }}
+            onChange={handleGroupByChange}
           />
-          <CompactSelect
-            triggerProps={{prefix: t('Display'), size: 'sm'}}
-            value={displayType ?? defaultMetricDisplayType}
-            options={[
-              {
-                value: MetricDisplayType.LINE,
-                label: t('Line'),
-              },
-              {
-                value: MetricDisplayType.AREA,
-                label: t('Area'),
-              },
-              {
-                value: MetricDisplayType.BAR,
-                label: t('Bar'),
-              },
-            ]}
-            onChange={({value}) => {
-              Sentry.metrics.increment('ddm.widget.display', 1, {
-                tags: {
-                  display: value,
-                  type: readableType,
-                  operation: metricsQuery.op,
-                  isGrouped: !!metricsQuery.groupBy?.length,
-                  isFiltered: !!metricsQuery.query,
-                },
-              });
-              onChange({displayType: value});
-            }}
-          />
-        </WrapPageFilterBar>
-      </QueryBuilderRow>
-      {/* Stop propagation so widget does not get selected immediately */}
-      <QueryBuilderRow onClick={stopPropagation}>
+        </FlexBlock>
+      </FlexBlock>
+      <SearchBarWrapper>
         <MetricSearchBar
           // TODO(aknaus): clean up projectId type in ddm
           projectIds={projects.map(id => id.toString())}
           mri={metricsQuery.mri}
           disabled={!metricsQuery.mri}
           onChange={query => {
-            Sentry.metrics.increment('ddm.widget.filter', 1, {
-              tags: {
-                display: displayType ?? defaultMetricDisplayType,
-                type: readableType,
-                operation: metricsQuery.op,
-                isGrouped: !!metricsQuery.groupBy?.length,
-                isFiltered: !!query,
-              },
-            });
+            incrementQueryMetric('ddm.widget.filter', {query});
             onChange({query});
           }}
           query={metricsQuery.query}
         />
-      </QueryBuilderRow>
+      </SearchBarWrapper>
     </QueryBuilderWrapper>
   );
 });
 
-function getWidgetDisplayType(
-  mri: MetricsQuery['mri'],
-  op: MetricsQuery['op']
-): MetricDisplayType {
-  if (mri?.startsWith('c') || op === 'count') {
-    return MetricDisplayType.BAR;
-  }
-  return MetricDisplayType.LINE;
-}
-
 const QueryBuilderWrapper = styled('div')`
   display: flex;
   flex-grow: 1;
-  flex-direction: column;
-`;
-
-const QueryBuilderRow = styled('div')`
-  padding: ${space(1)};
-  padding-bottom: 0;
+  gap: ${space(1)};
+  flex-wrap: wrap;
 `;
 
-const WrapPageFilterBar = styled(PageFilterBar)`
-  max-width: max-content;
-  height: auto;
+const FlexBlock = styled('div')`
+  display: flex;
+  gap: ${space(1)};
   flex-wrap: wrap;
 `;
 
-const WidgetTitle = styled(HeaderTitle)`
-  padding-left: ${space(2)};
-  padding-top: ${space(1.5)};
-  padding-right: ${space(1)};
+const SearchBarWrapper = styled('div')`
+  flex: 1;
+  min-width: 300px;
 `;
diff --git a/static/app/views/ddm/querySymbol.spec.tsx b/static/app/views/ddm/querySymbol.spec.tsx
new file mode 100644
index 00000000000000..46f920877e4f72
--- /dev/null
+++ b/static/app/views/ddm/querySymbol.spec.tsx
@@ -0,0 +1,16 @@
+import {getQuerySymbol} from 'sentry/views/ddm/querySymbol';
+
+describe('getQuerySymbol', () => {
+  it('should return the correct symbol', () => {
+    expect(getQuerySymbol(0)).toBe('A');
+    expect(getQuerySymbol(1)).toBe('B');
+    expect(getQuerySymbol(25)).toBe('Z');
+    expect(getQuerySymbol(26)).toBe('AA');
+    expect(getQuerySymbol(27)).toBe('AB');
+    expect(getQuerySymbol(52)).toBe('BA');
+    expect(getQuerySymbol(53)).toBe('BB');
+    expect(getQuerySymbol(77)).toBe('BZ');
+    expect(getQuerySymbol(78)).toBe('CA');
+    expect(getQuerySymbol(702)).toBe('AAA');
+  });
+});
diff --git a/static/app/views/ddm/querySymbol.tsx b/static/app/views/ddm/querySymbol.tsx
new file mode 100644
index 00000000000000..96b652aa903051
--- /dev/null
+++ b/static/app/views/ddm/querySymbol.tsx
@@ -0,0 +1,39 @@
+import styled from '@emotion/styled';
+
+const indexToChar = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+export const getQuerySymbol = (index: number) => {
+  let result = '';
+  let i = index;
+  do {
+    result = indexToChar[i % indexToChar.length] + result;
+    i = Math.floor(i / indexToChar.length) - 1;
+  } while (i >= 0);
+  return result;
+};
+
+const Symbol = styled('div')`
+  display: flex;
+  width: 16px;
+  height: 16px;
+  padding: 4px;
+  justify-content: center;
+  align-items: center;
+  flex-shrink: 0;
+  color: ${p => p.theme.black};
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  font-weight: 500;
+  background: ${p => p.theme.yellow300};
+  border-radius: 50%;
+`;
+
+export function QuerySymbol({
+  index,
+  ...props
+}: React.ComponentProps<typeof Symbol> & {index: number}) {
+  return (
+    <Symbol {...props}>
+      <span>{getQuerySymbol(index)}</span>
+    </Symbol>
+  );
+}
diff --git a/static/app/views/ddm/scratchpad.tsx b/static/app/views/ddm/scratchpad.tsx
index 481b8796740c4c..d3b5a813261580 100644
--- a/static/app/views/ddm/scratchpad.tsx
+++ b/static/app/views/ddm/scratchpad.tsx
@@ -1,16 +1,9 @@
 import {useCallback, useLayoutEffect} from 'react';
 import styled from '@emotion/styled';
-import * as Sentry from '@sentry/react';
 import * as echarts from 'echarts/core';
 
-import {Button} from 'sentry/components/button';
-import Panel from 'sentry/components/panels/panel';
-import {IconAdd} from 'sentry/icons';
-import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import {trackAnalytics} from 'sentry/utils/analytics';
 import {MetricWidgetQueryParams} from 'sentry/utils/metrics';
-import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import {DDM_CHART_GROUP, MIN_WIDGET_WIDTH} from 'sentry/views/ddm/constants';
 import {useDDMContext} from 'sentry/views/ddm/context';
@@ -23,13 +16,11 @@ export function MetricScratchpad() {
     selectedWidgetIndex,
     widgets,
     updateWidget,
-    addWidget,
     focusArea,
     addFocusArea,
     removeFocusArea,
   } = useDDMContext();
   const {selection} = usePageFilters();
-  const organization = useOrganization();
 
   // Make sure all charts are connected to the same group whenever the widgets definition changes
   useLayoutEffect(() => {
@@ -43,7 +34,6 @@ export function MetricScratchpad() {
     [updateWidget]
   );
 
-  const hasEmptyWidget = widgets.length === 0 || widgets.some(widget => !widget.mri);
   const Wrapper =
     widgets.length === 1 ? StyledSingleWidgetWrapper : StyledMetricDashboard;
 
@@ -66,25 +56,6 @@ export function MetricScratchpad() {
           focusArea={focusArea}
         />
       ))}
-      <AddWidgetPanel
-        disabled={hasEmptyWidget}
-        onClick={
-          !hasEmptyWidget
-            ? () => {
-                trackAnalytics('ddm.widget.add', {
-                  organization,
-                });
-                Sentry.metrics.increment('ddm.widget.add');
-
-                addWidget();
-              }
-            : undefined
-        }
-      >
-        <Button disabled={hasEmptyWidget} icon={<IconAdd isCircled />}>
-          {t('Add widget')}
-        </Button>
-      </AddWidgetPanel>
     </Wrapper>
   );
 }
@@ -105,33 +76,5 @@ const StyledMetricDashboard = styled('div')`
 
 const StyledSingleWidgetWrapper = styled('div')`
   display: grid;
-  grid-template-columns: minmax(${MIN_WIDGET_WIDTH}px, 90%) minmax(180px, 10%);
-
-  @media (max-width: ${props => props.theme.breakpoints.xlarge}) {
-    grid-template-columns: repeat(1, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
-  }
-
-  gap: ${space(2)};
-
-  grid-auto-rows: auto;
-`;
-
-const AddWidgetPanel = styled(Panel)<{disabled: boolean}>`
-  height: 100%;
-  margin-bottom: 0;
-  padding: ${space(4)};
-  font-size: ${p => p.theme.fontSizeExtraLarge};
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border: 1px dashed ${p => (p.disabled ? p.theme.disabledBorder : p.theme.border)};
-
-  ${p =>
-    !p.disabled &&
-    `
-    &:hover {
-      background-color: ${p.theme.backgroundSecondary};
-      cursor: pointer;
-    }
-  `}
+  grid-template-columns: repeat(1, minmax(${MIN_WIDGET_WIDTH}px, 1fr));
 `;
diff --git a/static/app/views/ddm/widget.tsx b/static/app/views/ddm/widget.tsx
index d7c6687b7a4136..e8fa022f23d9ab 100644
--- a/static/app/views/ddm/widget.tsx
+++ b/static/app/views/ddm/widget.tsx
@@ -1,4 +1,4 @@
-import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {memo, useCallback, useMemo, useRef} from 'react';
 import styled from '@emotion/styled';
 import colorFn from 'color';
 import type {LineSeriesOption} from 'echarts';
@@ -6,27 +6,32 @@ import moment from 'moment';
 
 import Alert from 'sentry/components/alert';
 import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
+import {CompactSelect, SelectOption} from 'sentry/components/compactSelect';
 import EmptyMessage from 'sentry/components/emptyMessage';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
+import {Tooltip} from 'sentry/components/tooltip';
 import {IconSearch} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {MetricsApiResponse, MRI, PageFilters} from 'sentry/types';
 import {ReactEchartsRef} from 'sentry/types/echarts';
 import {
+  getDefaultMetricDisplayType,
   getSeriesName,
   MetricDisplayType,
+  metricDisplayTypeOptions,
   MetricWidgetQueryParams,
+  stringifyMetricWidget,
 } from 'sentry/utils/metrics';
 import {parseMRI} from 'sentry/utils/metrics/mri';
+import {useIncrementQueryMetric} from 'sentry/utils/metrics/useIncrementQueryMetric';
 import {useMetricsDataZoom} from 'sentry/utils/metrics/useMetricsData';
 import theme from 'sentry/utils/theme';
 import {MetricChart} from 'sentry/views/ddm/chart';
 import {FocusArea} from 'sentry/views/ddm/chartBrush';
-import {MetricWidgetContextMenu} from 'sentry/views/ddm/contextMenu';
-import {QueryBuilder} from 'sentry/views/ddm/queryBuilder';
+import {QuerySymbol} from 'sentry/views/ddm/querySymbol';
 import {SummaryTable} from 'sentry/views/ddm/summaryTable';
 
 import {MIN_WIDGET_WIDTH} from './constants';
@@ -59,8 +64,6 @@ export const MetricWidget = memo(
     removeFocusArea: () => void;
     widget: MetricWidgetQueryParams;
   }) => {
-    const [isEdit, setIsEdit] = useState(true);
-
     const handleChange = useCallback(
       (data: Partial<MetricWidgetQueryParams>) => {
         onChange(index, data);
@@ -68,14 +71,6 @@ export const MetricWidget = memo(
       [index, onChange]
     );
 
-    useEffect(() => {
-      // exit the edit mode when the focus is lost
-      // it would work without it (because we do edit && focus) but when you focus again, we want the edit mode to be turned off by default
-      if (!isSelected) {
-        setIsEdit(false);
-      }
-    }, [isSelected]);
-
     const metricsQuery = useMemo(
       () => ({
         mri: widget.mri,
@@ -99,7 +94,20 @@ export const MetricWidget = memo(
       ]
     );
 
-    const shouldDisplayEditControls = (isEdit && isSelected) || !metricsQuery.mri;
+    const incrementQueryMetric = useIncrementQueryMetric({
+      displayType: widget.displayType,
+      op: metricsQuery.op,
+      groupBy: metricsQuery.groupBy,
+      query: metricsQuery.query,
+      mri: metricsQuery.mri,
+    });
+
+    const handleDisplayTypeChange = ({value}: SelectOption<MetricDisplayType>) => {
+      incrementQueryMetric('ddm.widget.display', {displayType: value});
+      onChange(index, {displayType: value});
+    };
+
+    const widgetTitle = stringifyMetricWidget(metricsQuery);
 
     return (
       <MetricWidgetPanel
@@ -110,20 +118,26 @@ export const MetricWidget = memo(
       >
         <PanelBody>
           <MetricWidgetHeader>
-            <QueryBuilder
-              metricsQuery={metricsQuery}
-              projects={projects}
-              displayType={widget.displayType}
-              onChange={handleChange}
-              powerUserMode={widget.powerUserMode}
-              isEdit={shouldDisplayEditControls}
-            />
-            <MetricWidgetContextMenu
-              widgetIndex={index}
-              metricsQuery={metricsQuery}
-              displayType={widget.displayType}
-              isEdit={shouldDisplayEditControls}
-              onEdit={() => setIsEdit(true)}
+            <QuerySymbol index={index} />
+            <WidgetTitle>
+              <StyledTooltip
+                title={widgetTitle}
+                showOnlyOnOverflow
+                delay={500}
+                overlayStyle={{maxWidth: '90vw'}}
+              >
+                {widgetTitle}
+              </StyledTooltip>
+            </WidgetTitle>
+            <CompactSelect
+              size="xs"
+              triggerProps={{prefix: t('Display')}}
+              value={
+                widget.displayType ??
+                getDefaultMetricDisplayType(metricsQuery.mri, metricsQuery.op)
+              }
+              options={metricDisplayTypeOptions}
+              onChange={handleDisplayTypeChange}
             />
           </MetricWidgetHeader>
           {widget.mri ? (
@@ -153,11 +167,6 @@ export const MetricWidget = memo(
   }
 );
 
-const MetricWidgetHeader = styled('div')`
-  display: flex;
-  justify-content: space-between;
-`;
-
 interface MetricWidgetProps extends MetricWidgetQueryParams {
   addFocusArea: (area: FocusArea) => void;
   focusArea: FocusArea | null;
@@ -416,3 +425,24 @@ const StyledMetricWidgetBody = styled('div')`
   flex-direction: column;
   justify-content: center;
 `;
+
+const MetricWidgetHeader = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: ${space(1)};
+  padding-left: ${space(2)};
+  padding-top: ${space(1.5)};
+  padding-right: ${space(2)};
+`;
+
+const WidgetTitle = styled('div')`
+  flex-grow: 1;
+  font-size: ${p => p.theme.fontSizeMedium};
+  display: inline-grid;
+  grid-auto-flow: column;
+`;
+
+const StyledTooltip = styled(Tooltip)`
+  ${p => p.theme.overflowEllipsis};
+`;


Code review comments:
diff_hunk: @@ -0,0 +1,39 @@
+import styled from '@emotion/styled';
+
+const indexToChar = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+export const getQuerySymbol = (index: number) => {
+  let result = '';
+  let i = index;
+  do {
+    result = indexToChar[i % indexToChar.length] + result;
+    i = Math.floor(i / indexToChar.length) - 1;
+  } while (i >= 0);
+  return result;
+};
+
+const Symbol = styled('div')`
+  display: flex;
+  width: 16px;
+  height: 16px;
+  padding: 4px;
path: static/app/views/ddm/querySymbol.tsx
text: @user1:
```suggestion
  padding: ${space(0.5)};
```