Browse Source

feat(alerts): Update metricSearchBar to support insights metric specific filter keys (#76566)

Insights metric alerts also support the `has` filter, and `span.module`
field alias filter. Neither of these come from the `tags` or `meta`
endpoints so we need to hard code them as a special case here.

Also adds a `isInsightsMetricAlert` helper function, which will be used
in later prs
edwardgou-sentry 6 months ago
parent
commit
66cd51edb1

+ 132 - 0
static/app/components/metrics/metricSearchBar.spec.tsx

@@ -0,0 +1,132 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {MetricSearchBar} from 'sentry/components/metrics/metricSearchBar';
+
+describe('metricSearchBar', function () {
+  const onChange = jest.fn();
+  beforeEach(() => {
+    onChange.mockReset();
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/metrics/meta/',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/metrics/tags/',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      method: 'POST',
+      url: '/organizations/org-slug/recent-searches/',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/recent-searches/',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/metrics/tags/potato/',
+      body: [],
+    });
+    MockApiClient.addMockResponse({
+      method: 'GET',
+      url: '/organizations/org-slug/metrics/tags/span.module/',
+      body: [],
+    });
+  });
+
+  describe('using SmartSearchBar', function () {
+    it('does not allow illegal filters', async function () {
+      render(
+        <MetricSearchBar onChange={onChange} mri="d:transactions/duration@millisecond" />
+      );
+      await screen.findByPlaceholderText('Filter by tags');
+      await userEvent.type(screen.getByPlaceholderText('Filter by tags'), 'potato:db');
+      expect(screen.getByTestId('search-autocomplete-item')).toHaveTextContent(
+        "The field potato isn't supported here."
+      );
+      await userEvent.keyboard('{enter}');
+      expect(onChange).not.toHaveBeenCalled();
+    });
+    it('does not allow insights filters when not using an insights mri', async function () {
+      render(
+        <MetricSearchBar onChange={onChange} mri="d:transactions/duration@millisecond" />
+      );
+      await screen.findByPlaceholderText('Filter by tags');
+      await userEvent.type(
+        screen.getByPlaceholderText('Filter by tags'),
+        'span.module:db'
+      );
+      expect(screen.getByTestId('search-autocomplete-item')).toHaveTextContent(
+        "The field span.module isn't supported here."
+      );
+      await userEvent.keyboard('{enter}');
+      expect(onChange).not.toHaveBeenCalled();
+    });
+    it('allows insights specific filters when using an insights mri', async function () {
+      render(
+        <MetricSearchBar onChange={onChange} mri="d:spans/exclusive_time@millisecond" />
+      );
+      await screen.findByPlaceholderText('Filter by tags');
+      await userEvent.type(
+        screen.getByPlaceholderText('Filter by tags'),
+        'span.module:db'
+      );
+      expect(screen.queryByTestId('search-autocomplete-item')).not.toBeInTheDocument();
+      await userEvent.keyboard('{enter}');
+      expect(onChange).toHaveBeenCalledWith('span.module:"db"');
+    });
+  });
+
+  describe('using SearchQueryBuilder', function () {
+    const organization = {features: ['search-query-builder-metrics']};
+    it('does not allow illegal filters', async function () {
+      render(
+        <MetricSearchBar onChange={onChange} mri="d:transactions/duration@millisecond" />,
+        {
+          organization,
+        }
+      );
+      await screen.findByPlaceholderText('Filter by tags');
+      await userEvent.type(screen.getByPlaceholderText('Filter by tags'), 'potato:db');
+      await userEvent.keyboard('{enter}');
+      screen.getByText('Invalid key. "potato" is not a supported search key.');
+      expect(onChange).not.toHaveBeenCalled();
+    });
+    it('does not allow insights filters when not using an insights mri', async function () {
+      render(
+        <MetricSearchBar onChange={onChange} mri="d:transactions/duration@millisecond" />,
+        {
+          organization,
+        }
+      );
+      await screen.findByPlaceholderText('Filter by tags');
+      await userEvent.type(
+        screen.getByPlaceholderText('Filter by tags'),
+        'span.module:db'
+      );
+      await userEvent.keyboard('{enter}');
+      screen.getByText('Invalid key. "span.module" is not a supported search key.');
+      expect(onChange).not.toHaveBeenCalled();
+    });
+    it('allows insights specific filters when using an insights mri', async function () {
+      render(
+        <MetricSearchBar onChange={onChange} mri="d:spans/exclusive_time@millisecond" />,
+        {
+          organization,
+        }
+      );
+      await screen.findByPlaceholderText('Filter by tags');
+      await userEvent.type(
+        screen.getByPlaceholderText('Filter by tags'),
+        'span.module:db'
+      );
+      await userEvent.keyboard('{enter}');
+      expect(
+        screen.queryByText('Invalid key. "span.module" is not a supported search key.')
+      ).not.toBeInTheDocument();
+      expect(onChange).toHaveBeenCalledWith('span.module:"db"');
+    });
+  });
+});

+ 22 - 2
static/app/components/metrics/metricSearchBar.tsx

@@ -17,10 +17,13 @@ import {
   hasMetricsNewSearchQueryBuilder,
 } from 'sentry/utils/metrics/features';
 import {getUseCaseFromMRI} from 'sentry/utils/metrics/mri';
+import type {MetricTag} from 'sentry/utils/metrics/types';
 import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
+import {INSIGHTS_METRICS} from 'sentry/views/alerts/rules/metric/utils/isInsightsMetricAlert';
+import {SpanMetricsField} from 'sentry/views/insights/types';
 import {ensureQuotedTextFilters} from 'sentry/views/metrics/utils';
 import {useSelectedProjects} from 'sentry/views/metrics/utils/useSelectedProjects';
 
@@ -36,6 +39,14 @@ export interface MetricSearchBarProps
 
 const EMPTY_ARRAY = [];
 const EMPTY_SET = new Set<never>();
+const INSIGHTS_ADDITIONAL_TAG_FILTERS: MetricTag[] = [
+  {
+    key: 'has',
+  },
+  {
+    key: SpanMetricsField.SPAN_MODULE,
+  },
+];
 
 export function MetricSearchBar({
   mri,
@@ -67,9 +78,18 @@ export function MetricSearchBar({
     blockedTags
   );
 
+  const additionalTags: MetricTag[] = useMemo(
+    () =>
+      // Insights metrics allow the `has` filter.
+      // `span.module` is a discover field alias that does not appear in the metrics meta endpoint.
+      INSIGHTS_METRICS.includes(mri as string) ? INSIGHTS_ADDITIONAL_TAG_FILTERS : [],
+    [mri]
+  );
+
   const supportedTags: TagCollection = useMemo(
-    () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
-    [tags]
+    () =>
+      [...tags, ...additionalTags].reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}),
+    [tags, additionalTags]
   );
 
   const searchConfig = useMemo(

+ 34 - 0
static/app/views/alerts/rules/metric/utils/isInsightsMetricAlert.tsx

@@ -0,0 +1,34 @@
+import {parseField} from 'sentry/utils/metrics/mri';
+
+export const INSIGHTS_METRICS_OPERATIONS = [
+  {
+    label: 'spm',
+    value: 'spm',
+  },
+];
+
+export const INSIGHTS_METRICS = [
+  'd:spans/webvital.inp@millisecond',
+  'd:spans/duration@millisecond',
+  'd:spans/exclusive_time@millisecond',
+  'd:spans/http.response_content_length@byte',
+  'd:spans/http.decoded_response_content_length@byte',
+  'd:spans/http.response_transfer_size@byte',
+  'd:spans/cache.item_size@byte',
+  'g:spans/messaging.message.receive.latency@millisecond',
+  'g:spans/mobile.frames_delay@second',
+  'g:spans/mobile.total_frames@none',
+  'g:spans/mobile.frozen_frames@none',
+  'g:spans/mobile.slow_frames@none',
+];
+
+export const isInsightsMetricAlert = (aggregate: string) => {
+  const {mri, aggregation} = parseField(aggregate) ?? {};
+  if (
+    INSIGHTS_METRICS.includes(mri as string) ||
+    INSIGHTS_METRICS_OPERATIONS.map(({value}) => value).includes(aggregation as string)
+  ) {
+    return true;
+  }
+  return false;
+};