Browse Source

feat(metrics-extraction): frontend CRUD (#73003)

Basic CRUD for metrics extraction rules.

Closes https://github.com/getsentry/sentry/issues/72849
ArthurKnaus 8 months ago
parent
commit
4ba8463c8a

+ 0 - 28
static/app/utils/metrics/formatters.tsx

@@ -128,34 +128,6 @@ export const formattingSupportedMetricUnits = [
 export type FormattingSupportedMetricUnit =
   (typeof formattingSupportedMetricUnits)[number];
 
-export const formattingSupportedMetricUnitsSingular: FormattingSupportedMetricUnit[] = [
-  'none',
-  'nanosecond',
-  'microsecond',
-  'millisecond',
-  'second',
-  'minute',
-  'hour',
-  'day',
-  'week',
-  'ratio',
-  'percent',
-  'bit',
-  'byte',
-  'kibibyte',
-  'kilobyte',
-  'mebibyte',
-  'megabyte',
-  'gibibyte',
-  'gigabyte',
-  'tebibyte',
-  'terabyte',
-  'pebibyte',
-  'petabyte',
-  'exbibyte',
-  'exabyte',
-];
-
 const METRIC_UNIT_TO_SHORT: Record<FormattingSupportedMetricUnit, string> = {
   nanosecond: 'ns',
   nanoseconds: 'ns',

+ 60 - 101
static/app/views/settings/projectMetrics/extractMetric.tsx

@@ -1,72 +1,89 @@
-import {Fragment, useState} from 'react';
+import {Fragment, useCallback} from 'react';
 
+import {addSuccessMessage} from 'sentry/actionCreators/indicator';
 import Alert from 'sentry/components/alert';
-import SelectField from 'sentry/components/forms/fields/selectField';
-import Form from 'sentry/components/forms/form';
 import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
 import PanelHeader from 'sentry/components/panels/panelHeader';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t} from 'sentry/locale';
-import type {MetricType} from 'sentry/types/metrics';
 import type {Project} from 'sentry/types/project';
 import {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
-import {
-  type FormattingSupportedMetricUnit,
-  formattingSupportedMetricUnitsSingular,
-} from 'sentry/utils/metrics/formatters';
 import routeTitleGen from 'sentry/utils/routeTitle';
 import {useNavigate} from 'sentry/utils/useNavigate';
 import useOrganization from 'sentry/utils/useOrganization';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
-
-interface FormData {
-  metricType: MetricType | null;
-  spanAttribute: string | null;
-  tags: string[];
-  unit: FormattingSupportedMetricUnit;
-}
+import {
+  type FormData,
+  MetricsExtractionRuleForm,
+} from 'sentry/views/settings/projectMetrics/metricsExtractionRuleForm';
+import {
+  type MetricsExtractionRule,
+  useCreateMetricsExtractionRules,
+} from 'sentry/views/settings/projectMetrics/utils/api';
 
 const INITIAL_DATA: FormData = {
   spanAttribute: null,
-  metricType: 'c',
-  tags: [],
-  unit: 'none',
+  type: 'c',
+  tags: ['release', 'environment'],
+  conditions: [''],
 };
 
 const PAGE_TITLE = t('Extract Metric');
 
-const TYPE_OPTIONS = [
-  {label: t('Counter'), value: 'c'},
-  {label: t('Gauge'), value: 'g'},
-  {label: t('Set'), value: 's'},
-  {label: t('Distribution'), value: 'd'},
-];
-
-const UNIT_OPTIONS = formattingSupportedMetricUnitsSingular.map(value => ({
-  label: value,
-  value,
-}));
-
 function ExtractMetric({project}: {project: Project}) {
   const navigate = useNavigate();
-  const [isUsingCounter, setIsUsingCounter] = useState(INITIAL_DATA.metricType === 'c');
   const organization = useOrganization();
+  const createExtractionRuleMutation = useCreateMetricsExtractionRules(
+    organization.slug,
+    project.slug
+  );
+
+  const handleSubmit = useCallback(
+    (
+      formData: FormData,
+      onSubmitSuccess: (data: FormData) => void,
+      onSubmitError: (error: any) => void
+    ) => {
+      const data = formData as FormData;
+
+      const extractionRule: MetricsExtractionRule = {
+        spanAttribute: data.spanAttribute!,
+        tags: data.tags,
+        type: data.type!,
+        unit: 'none',
+        conditions: data.conditions.filter(Boolean),
+      };
+
+      createExtractionRuleMutation.mutate(
+        {
+          metricsExtractionRules: [extractionRule],
+        },
+        {
+          onSuccess: () => {
+            onSubmitSuccess(data);
+            addSuccessMessage(t('Metric extraction rule created'));
+            navigate(-1);
+          },
+          onError: error => {
+            onSubmitError(
+              error?.responseJSON?.detail
+                ? error.responseJSON.detail
+                : t('Unable to save your changes.')
+            );
+          },
+        }
+      );
+      onSubmitSuccess(data);
+    },
+    [createExtractionRuleMutation, navigate]
+  );
 
   if (!hasCustomMetricsExtractionRules(organization)) {
     return <Alert type="warning">{t("You don't have access to this feature")}</Alert>;
   }
 
-  function handleSubmit(
-    formData: Record<string, any>,
-    onSubmitSuccess: (data: Record<string, any>) => void
-  ) {
-    const data = formData as FormData;
-    // TODO BE request
-    onSubmitSuccess(data);
-  }
-
   return (
     <Fragment>
       <SentryDocumentTitle title={routeTitleGen(PAGE_TITLE, project.slug, false)} />
@@ -84,72 +101,14 @@ function ExtractMetric({project}: {project: Project}) {
       <Panel>
         <PanelHeader>{t('Create Extraction Rule')}</PanelHeader>
         <PanelBody>
-          <Form
+          <MetricsExtractionRuleForm
+            project={project}
             initialData={INITIAL_DATA}
             onCancel={() => navigate(-1)}
             submitLabel={t('Confirm')}
-            onFieldChange={(name, value) => {
-              if (name === 'metricType') {
-                setIsUsingCounter(value === 'c');
-              }
-            }}
             onSubmit={handleSubmit}
             requireChanges
-          >
-            {({model}) => (
-              <Fragment>
-                <SelectField
-                  name="spanAttribute"
-                  required
-                  options={[
-                    {label: 'attribute1', value: 'attribute1'},
-                    {label: 'attribute2', value: 'attribute2'},
-                    {label: 'attribute3', value: 'attribute3'},
-                    {label: 'attribute4', value: 'attribute4'},
-                  ]}
-                  label="Span Attribute"
-                  help={t('The span attribute to extract the metric from.')}
-                />
-                <SelectField
-                  name="metricType"
-                  options={TYPE_OPTIONS}
-                  onChange={(value: string) => {
-                    if (value === 'c') {
-                      model.setValue('unit', 'none');
-                    }
-                  }}
-                  label="Type"
-                  help={t(
-                    'The type of the metric determines which aggregation functions are available and what types of values it can store. For more information, read our docs'
-                  )}
-                />
-                <SelectField
-                  name="unit"
-                  disabled={isUsingCounter}
-                  disabledReason={t('Counters do not support units')}
-                  options={UNIT_OPTIONS}
-                  label="Unit"
-                  help={t(
-                    'The unit of the metric. This will be used to format the metric values in the UI.'
-                  )}
-                />
-                <SelectField
-                  name="tags"
-                  options={[
-                    {label: 'tag1', value: 'tag1'},
-                    {label: 'tag2', value: 'tag2'},
-                    {label: 'tag3', value: 'tag3'},
-                    {label: 'tag4', value: 'tag4'},
-                  ]}
-                  label="Tags"
-                  multiple
-                  help={t(
-                    'Those tags will be stored with the metric. They can be used to filter and group the metric in the UI.'
-                  )}
-                />
-              </Fragment>
-            )}
-          </Form>
+          />
         </PanelBody>
       </Panel>
     </Fragment>

+ 121 - 0
static/app/views/settings/projectMetrics/metricsExtractionRuleEditModal.tsx

@@ -0,0 +1,121 @@
+import {Fragment, useCallback, useMemo} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {addSuccessMessage} from 'sentry/actionCreators/indicator';
+import type {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {t} from 'sentry/locale';
+import type {Project} from 'sentry/types/project';
+import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
+import useOrganization from 'sentry/utils/useOrganization';
+import {
+  type FormData,
+  MetricsExtractionRuleForm,
+} from 'sentry/views/settings/projectMetrics/metricsExtractionRuleForm';
+import {
+  type MetricsExtractionRule,
+  useUpdateMetricsExtractionRules,
+} from 'sentry/views/settings/projectMetrics/utils/api';
+
+interface Props extends ModalRenderProps {
+  metricExtractionRule: MetricsExtractionRule;
+  project: Project;
+}
+
+export function MetricsExtractionRuleEditModal({
+  Header,
+  Body,
+  closeModal,
+  CloseButton,
+  metricExtractionRule,
+  project,
+}: Props) {
+  const organization = useOrganization();
+  const updateExtractionRuleMutation = useUpdateMetricsExtractionRules(
+    organization.slug,
+    project.slug
+  );
+
+  const initialData: FormData = useMemo(() => {
+    return {
+      spanAttribute: metricExtractionRule.spanAttribute,
+      type: metricExtractionRule.type,
+      tags: metricExtractionRule.tags,
+      conditions: metricExtractionRule.conditions.length
+        ? metricExtractionRule.conditions
+        : [''],
+    };
+  }, [metricExtractionRule]);
+
+  const handleSubmit = useCallback(
+    (
+      data: FormData,
+      onSubmitSuccess: (data: FormData) => void,
+      onSubmitError: (error: any) => void
+    ) => {
+      const extractionRule: MetricsExtractionRule = {
+        spanAttribute: data.spanAttribute!,
+        tags: data.tags,
+        type: data.type!,
+        unit: 'none',
+        conditions: data.conditions.filter(Boolean),
+      };
+
+      updateExtractionRuleMutation.mutate(
+        {
+          metricsExtractionRules: [extractionRule],
+        },
+        {
+          onSuccess: () => {
+            onSubmitSuccess(data);
+            addSuccessMessage(t('Metric extraction rule updated'));
+            closeModal();
+          },
+          onError: error => {
+            onSubmitError(
+              error?.responseJSON?.detail
+                ? error.responseJSON.detail
+                : t('Unable to save your changes.')
+            );
+          },
+        }
+      );
+      onSubmitSuccess(data);
+    },
+    [closeModal, updateExtractionRuleMutation]
+  );
+
+  return (
+    <Fragment>
+      <Header>
+        <h4>
+          <Capitalize>{getReadableMetricType(metricExtractionRule.type)}</Capitalize>
+          {' — '}
+          {metricExtractionRule.spanAttribute}
+        </h4>
+      </Header>
+      <CloseButton />
+      <Body>
+        <MetricsExtractionRuleForm
+          initialData={initialData}
+          project={project}
+          submitLabel={t('Update')}
+          cancelLabel={t('Cancel')}
+          onCancel={closeModal}
+          onSubmit={handleSubmit}
+          isEdit
+          requireChanges
+        />
+      </Body>
+    </Fragment>
+  );
+}
+
+export const modalCss = css`
+  width: 100%;
+  max-width: 1000px;
+`;
+
+const Capitalize = styled('span')`
+  text-transform: capitalize;
+`;

+ 267 - 0
static/app/views/settings/projectMetrics/metricsExtractionRuleForm.tsx

@@ -0,0 +1,267 @@
+import {Fragment, useCallback, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import SearchBar from 'sentry/components/events/searchBar';
+import SelectField from 'sentry/components/forms/fields/selectField';
+import Form, {type FormProps} from 'sentry/components/forms/form';
+import FormField from 'sentry/components/forms/formField';
+import type FormModel from 'sentry/components/forms/model';
+import {BooleanOperator} from 'sentry/components/searchSyntax/parser';
+import {IconAdd, IconClose} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {MetricType} from 'sentry/types/metrics';
+import type {Project} from 'sentry/types/project';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
+
+export interface FormData {
+  conditions: string[];
+  spanAttribute: string | null;
+  tags: string[];
+  type: MetricType | null;
+}
+
+interface Props extends Omit<FormProps, 'onSubmit'> {
+  initialData: FormData;
+  project: Project;
+  isEdit?: boolean;
+  onSubmit?: (
+    data: FormData,
+    onSubmitSuccess: (data: FormData) => void,
+    onSubmitError: (error: any) => void,
+    event: React.FormEvent,
+    model: FormModel
+  ) => void;
+}
+
+const ListItemDetails = styled('span')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+  text-align: right;
+  line-height: 1.2;
+`;
+
+const TYPE_OPTIONS = [
+  {
+    label: t('Counter'),
+    value: 'c',
+    trailingItems: [<ListItemDetails key="aggregates">{t('count')}</ListItemDetails>],
+  },
+  {
+    label: t('Set'),
+    value: 's',
+    trailingItems: [
+      <ListItemDetails key="aggregates">{t('count_unique')}</ListItemDetails>,
+    ],
+  },
+  {
+    label: t('Distribution'),
+    value: 'd',
+    trailingItems: [
+      <ListItemDetails key="aggregates">
+        {t('count, avg, sum, min, max, percentiles')}
+      </ListItemDetails>,
+    ],
+  },
+];
+
+const DISALLOWED_LOGICAL_OPERATORS = new Set([BooleanOperator.AND, BooleanOperator.OR]);
+
+export function MetricsExtractionRuleForm({isEdit, project, onSubmit, ...props}: Props) {
+  const [customAttributes, setCustomeAttributes] = useState<string[]>(() => {
+    const {spanAttribute, tags} = props.initialData;
+    return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)];
+  });
+  const organization = useOrganization();
+  const tags = useSpanFieldSupportedTags({projects: [parseInt(project.id, 10)]});
+
+  // TODO(aknaus): Make this nicer
+  const supportedTags = useMemo(() => {
+    const copy = {...tags};
+    delete copy.has;
+    return copy;
+  }, [tags]);
+
+  const attributeOptions = useMemo(() => {
+    let keys = Object.keys(supportedTags);
+
+    if (customAttributes.length) {
+      keys = [...new Set(keys.concat(customAttributes))];
+    }
+
+    return keys
+      .map(key => ({
+        label: key,
+        value: key,
+      }))
+      .sort((a, b) => a.label.localeCompare(b.label));
+  }, [customAttributes, supportedTags]);
+
+  const handleSubmit = useCallback(
+    (
+      data: Record<string, any>,
+      onSubmitSuccess: (data: Record<string, any>) => void,
+      onSubmitError: (error: any) => void,
+      event: React.FormEvent,
+      model: FormModel
+    ) => {
+      onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model);
+    },
+    [onSubmit]
+  );
+
+  return (
+    <Form onSubmit={onSubmit && handleSubmit} {...props}>
+      {({model}) => (
+        <Fragment>
+          <SelectField
+            name="spanAttribute"
+            options={attributeOptions}
+            disabled={isEdit}
+            label={t('Span Attribute')}
+            help={t('The span attribute to extract the metric from.')}
+            placeholder={t('Select an attribute')}
+            creatable
+            formatCreateLabel={value => `Custom: "${value}"`}
+            onCreateOption={value => {
+              setCustomeAttributes(curr => [...curr, value]);
+              model.setValue('spanAttribute', value);
+            }}
+            required
+          />
+          <SelectField
+            name="type"
+            disabled={isEdit}
+            options={TYPE_OPTIONS}
+            label={t('Type')}
+            help={t(
+              'The type of the metric determines which aggregation functions are available and what types of values it can store. For more information, read our docs'
+            )}
+          />
+          <SelectField
+            name="tags"
+            options={attributeOptions}
+            label={t('Tags')}
+            multiple
+            placeholder={t('Select tags')}
+            help={t(
+              'Those tags will be stored with the metric. They can be used to filter and group the metric in the UI.'
+            )}
+            creatable
+            formatCreateLabel={value => `Custom: "${value}"`}
+            onCreateOption={value => {
+              setCustomeAttributes(curr => [...curr, value]);
+              const currentTags = model.getValue('tags') as string[];
+              model.setValue('tags', [...currentTags, value]);
+            }}
+          />
+          <FormField
+            label={t('Filters')}
+            help={t(
+              'Define filters for spans. The metric will be extracted only from spans that match these conditions.'
+            )}
+            name="conditions"
+            inline={false}
+            hasControlState={false}
+            flexibleControlStateSize
+          >
+            {({onChange, initialData, value}) => {
+              const conditions = (value || initialData) as string[];
+              return (
+                <Fragment>
+                  <ConditionsWrapper hasDelete={value.length > 1}>
+                    {conditions.map((query, index) => (
+                      <Fragment key={index}>
+                        <SearchWrapper hasPrefix={index !== 0}>
+                          {index !== 0 && <ConditionLetter>{t('or')}</ConditionLetter>}
+                          <SearchBar
+                            searchSource="metrics-extraction"
+                            query={query}
+                            onSearch={(queryString: string) =>
+                              onChange(conditions.toSpliced(index, 1, queryString), {})
+                            }
+                            placeholder={t('Search for span attributes')}
+                            organization={organization}
+                            metricAlert={false}
+                            supportedTags={supportedTags}
+                            dataset={DiscoverDatasets.SPANS_INDEXED}
+                            projectIds={[parseInt(project.id, 10)]}
+                            hasRecentSearches={false}
+                            disallowedLogicalOperators={DISALLOWED_LOGICAL_OPERATORS}
+                            disallowWildcard
+                            onBlur={(queryString: string) =>
+                              onChange(conditions.toSpliced(index, 1, queryString), {})
+                            }
+                          />
+                        </SearchWrapper>
+                        {value.length > 1 && (
+                          <Button
+                            aria-label={t('Remove Condition')}
+                            onClick={() => onChange(conditions.toSpliced(index, 1), {})}
+                            icon={<IconClose />}
+                          />
+                        )}
+                      </Fragment>
+                    ))}
+                  </ConditionsWrapper>
+                  <ConditionsButtonBar>
+                    <Button
+                      onClick={() => onChange([...conditions, ''], {})}
+                      icon={<IconAdd />}
+                    >
+                      {t('Add condition')}
+                    </Button>
+                  </ConditionsButtonBar>
+                </Fragment>
+              );
+            }}
+          </FormField>
+        </Fragment>
+      )}
+    </Form>
+  );
+}
+
+const ConditionsWrapper = styled('div')<{hasDelete: boolean}>`
+  display: grid;
+  gap: ${space(1)};
+  ${p =>
+    p.hasDelete
+      ? `
+  grid-template-columns: 1fr min-content;
+  `
+      : `
+  grid-template-columns: 1fr;
+  `}
+`;
+
+const SearchWrapper = styled('div')<{hasPrefix: boolean}>`
+  display: grid;
+  gap: ${space(1)};
+  ${p =>
+    p.hasPrefix
+      ? `
+  grid-template-columns: max-content 1fr;
+  `
+      : `
+  grid-template-columns: 1fr;
+  `}
+`;
+
+const ConditionLetter = styled('div')`
+  background-color: ${p => p.theme.purple100};
+  border-radius: ${p => p.theme.borderRadius};
+  text-align: center;
+  padding: 0 ${space(2)};
+  color: ${p => p.theme.purple400};
+  white-space: nowrap;
+  font-weight: ${p => p.theme.fontWeightBold};
+  align-content: center;
+`;
+
+const ConditionsButtonBar = styled('div')`
+  margin-top: ${space(1)};
+`;

+ 126 - 9
static/app/views/settings/projectMetrics/projectMetrics.tsx

@@ -4,8 +4,11 @@ import styled from '@emotion/styled';
 import * as Sentry from '@sentry/react';
 import debounce from 'lodash/debounce';
 
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {openModal} from 'sentry/actionCreators/modal';
 import Tag from 'sentry/components/badge/tag';
 import {Button, LinkButton} from 'sentry/components/button';
+import {openConfirmModal} from 'sentry/components/confirm';
 import ExternalLink from 'sentry/components/links/externalLink';
 import Link from 'sentry/components/links/link';
 import {PanelTable} from 'sentry/components/panels/panelTable';
@@ -14,7 +17,7 @@ import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
 import {Tooltip} from 'sentry/components/tooltip';
 import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
-import {IconArrow, IconWarning} from 'sentry/icons';
+import {IconArrow, IconDelete, IconEdit, IconWarning} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {MetricMeta} from 'sentry/types/metrics';
@@ -42,6 +45,15 @@ import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
 import {useAccess} from 'sentry/views/settings/projectMetrics/access';
 import {BlockButton} from 'sentry/views/settings/projectMetrics/blockButton';
 import {CardinalityLimit} from 'sentry/views/settings/projectMetrics/cardinalityLimit';
+import {
+  MetricsExtractionRuleEditModal,
+  modalCss,
+} from 'sentry/views/settings/projectMetrics/metricsExtractionRuleEditModal';
+import {
+  type MetricsExtractionRule,
+  useDeleteMetricsExtractionRules,
+  useMetricsExtractionRules,
+} from 'sentry/views/settings/projectMetrics/utils/api';
 
 type Props = {
   organization: Organization;
@@ -119,6 +131,12 @@ function ProjectMetrics({project, location}: Props) {
 
   const hasExtractionRules = hasCustomMetricsExtractionRules(organization);
 
+  const extractionRulesQuery = useMetricsExtractionRules(organization.slug, project.slug);
+  const deleteExtractionRulesMutation = useDeleteMetricsExtractionRules(
+    organization.slug,
+    project.slug
+  );
+
   return (
     <Fragment>
       <SentryDocumentTitle title={routeTitleGen(t('Metrics'), project.slug, false)} />
@@ -167,7 +185,40 @@ function ProjectMetrics({project, location}: Props) {
               {t('Add Extraction Rule')}
             </LinkButton>
           </ExtractionRulesSearchWrapper>
-          <MetricsExtractionTable isLoading={false} extractionRules={[]} />
+          <MetricsExtractionTable
+            isLoading={extractionRulesQuery.isLoading}
+            onDelete={rule =>
+              openConfirmModal({
+                onConfirm: () =>
+                  deleteExtractionRulesMutation.mutate(
+                    {metricsExtractionRules: [rule]},
+                    {
+                      onSuccess: () => {
+                        addSuccessMessage(t('Metric extraction rule deleted'));
+                      },
+                      onError: () => {
+                        addErrorMessage(t('Failed to delete metric extraction rule'));
+                      },
+                    }
+                  ),
+                message: t('Are you sure you want to delete this extraction rule?'),
+                confirmText: t('Delete Extraction Rule'),
+              })
+            }
+            onEdit={rule => {
+              openModal(
+                props => (
+                  <MetricsExtractionRuleEditModal
+                    project={project}
+                    metricExtractionRule={rule}
+                    {...props}
+                  />
+                ),
+                {modalCss}
+              );
+            }}
+            extractionRules={extractionRulesQuery.data ?? []}
+          />
         </Fragment>
       )}
 
@@ -212,24 +263,34 @@ function ProjectMetrics({project, location}: Props) {
 }
 
 interface MetricsExtractionTableProps {
-  extractionRules: never[];
+  extractionRules: MetricsExtractionRule[];
   isLoading: boolean;
+  onDelete: (rule: MetricsExtractionRule) => void;
+  onEdit: (rule: MetricsExtractionRule) => void;
 }
 
 function MetricsExtractionTable({
   extractionRules,
   isLoading,
+  onDelete,
+  onEdit,
 }: MetricsExtractionTableProps) {
   return (
-    <StyledPanelTable
+    <ExtractionRulesPanelTable
       headers={[
-        t('Span attribute'),
+        <Cell key="spanAttribute">
+          <IconArrow size="xs" direction="down" />
+          {t('Span attribute')}
+        </Cell>,
         <Cell right key="type">
           {t('Type')}
         </Cell>,
         <Cell right key="unit">
           {t('Unit')}
         </Cell>,
+        <Cell right key="filters">
+          {t('Filters')}
+        </Cell>,
         <Cell right key="tags">
           {t('Tags')}
         </Cell>,
@@ -240,7 +301,55 @@ function MetricsExtractionTable({
       emptyMessage={t('You have not created any extraction rules yet.')}
       isEmpty={extractionRules.length === 0}
       isLoading={isLoading}
-    />
+    >
+      {extractionRules
+        .toSorted((a, b) => a?.spanAttribute?.localeCompare(b?.spanAttribute))
+        .map(rule => (
+          <Fragment key={rule.spanAttribute + rule.type + rule.unit}>
+            <Cell>{rule.spanAttribute}</Cell>
+            <Cell right>
+              <Tag>{getReadableMetricType(rule.type)}</Tag>
+            </Cell>
+            <Cell right>
+              <Tag>{rule.unit}</Tag>
+            </Cell>
+            <Cell right>
+              {rule.conditions.length ? (
+                <Button priority="link" onClick={() => onEdit(rule)}>
+                  {rule.conditions.length}
+                </Button>
+              ) : (
+                <NoValue>{t('(none)')}</NoValue>
+              )}
+            </Cell>
+            <Cell right>
+              {rule.tags.length ? (
+                <Button priority="link" onClick={() => onEdit(rule)}>
+                  {rule.tags.length}
+                </Button>
+              ) : (
+                <NoValue>{t('(none)')}</NoValue>
+              )}
+            </Cell>
+            <Cell right>
+              <Button
+                aria-label={t('Delete rule')}
+                size="xs"
+                icon={<IconDelete />}
+                borderless
+                onClick={() => onDelete(rule)}
+              />
+              <Button
+                aria-label={t('Edit rule')}
+                size="xs"
+                icon={<IconEdit />}
+                borderless
+                onClick={() => onEdit(rule)}
+              />
+            </Cell>
+          </Fragment>
+        ))}
+    </ExtractionRulesPanelTable>
   );
 }
 
@@ -258,7 +367,7 @@ function MetricsTable({metrics, isLoading, query, project}: MetricsTableProps) {
     project.relayCustomMetricCardinalityLimit ?? DEFAULT_METRICS_CARDINALITY_LIMIT;
 
   return (
-    <StyledPanelTable
+    <MetricsPanelTable
       headers={[
         t('Metric'),
         <Cell right key="cardinality">
@@ -335,7 +444,7 @@ function MetricsTable({metrics, isLoading, query, project}: MetricsTableProps) {
           </Fragment>
         );
       })}
-    </StyledPanelTable>
+    </MetricsPanelTable>
   );
 }
 
@@ -359,10 +468,14 @@ const ExtractionRulesSearchWrapper = styled(SearchWrapper)`
   margin-bottom: ${space(1)};
 `;
 
-const StyledPanelTable = styled(PanelTable)`
+const MetricsPanelTable = styled(PanelTable)`
   grid-template-columns: 1fr repeat(4, min-content);
 `;
 
+const ExtractionRulesPanelTable = styled(PanelTable)`
+  grid-template-columns: 1fr repeat(5, min-content);
+`;
+
 const Cell = styled('div')<{right?: boolean}>`
   display: flex;
   align-items: center;
@@ -378,4 +491,8 @@ const StyledIconWarning = styled(IconWarning)`
   }
 `;
 
+const NoValue = styled('span')`
+  color: ${p => p.theme.subText};
+`;
+
 export default ProjectMetrics;

+ 164 - 0
static/app/views/settings/projectMetrics/utils/api.tsx

@@ -0,0 +1,164 @@
+import type {MetricType} from 'sentry/types/metrics';
+import type {FormattingSupportedMetricUnit} from 'sentry/utils/metrics/formatters';
+import {
+  getApiQueryData,
+  setApiQueryData,
+  useApiQuery,
+  useMutation,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+
+const getMetricsExtractionRulesEndpoint = (orgSlug: string, projectSlug: string) =>
+  [`/projects/${orgSlug}/${projectSlug}/metrics/extraction-rules/`] as const;
+
+export interface MetricsExtractionRule {
+  conditions: string[];
+  spanAttribute: string;
+  tags: string[];
+  type: MetricType;
+  unit: FormattingSupportedMetricUnit;
+}
+
+export function useMetricsExtractionRules(orgSlug: string, projectSlug: string) {
+  return useApiQuery<MetricsExtractionRule[]>(
+    getMetricsExtractionRulesEndpoint(orgSlug, projectSlug),
+    {
+      staleTime: 0,
+      retry: false,
+    }
+  );
+}
+
+// Rules are identified by the combination of span_attribute, type and unit
+function getRuleIdentifier(rule: MetricsExtractionRule) {
+  return rule.spanAttribute + rule.type + rule.unit;
+}
+
+export function useDeleteMetricsExtractionRules(orgSlug: string, projectSlug: string) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+  const queryKey = getMetricsExtractionRulesEndpoint(orgSlug, projectSlug);
+
+  return useMutation<
+    MetricsExtractionRule[],
+    RequestError,
+    {metricsExtractionRules: MetricsExtractionRule[]},
+    {previous?: MetricsExtractionRule[]}
+  >(
+    data => {
+      return api.requestPromise(queryKey[0], {
+        method: 'DELETE',
+        data,
+      });
+    },
+    {
+      onMutate: data => {
+        queryClient.cancelQueries(queryKey);
+
+        const previous = getApiQueryData<MetricsExtractionRule[]>(queryClient, queryKey);
+
+        const deletedRules = data.metricsExtractionRules;
+        const deletedKeys = new Set(deletedRules.map(getRuleIdentifier));
+
+        setApiQueryData<MetricsExtractionRule[]>(queryClient, queryKey, oldRules => {
+          return oldRules?.filter(rule => !deletedKeys.has(getRuleIdentifier(rule)));
+        });
+
+        return {previous};
+      },
+      onError: (_error, _variables, context) => {
+        if (context?.previous) {
+          setApiQueryData<MetricsExtractionRule[]>(
+            queryClient,
+            queryKey,
+            context.previous
+          );
+        }
+      },
+      onSettled: () => {
+        queryClient.invalidateQueries(queryKey);
+      },
+    }
+  );
+}
+
+export function useCreateMetricsExtractionRules(orgSlug: string, projectSlug: string) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+  const queryKey = getMetricsExtractionRulesEndpoint(orgSlug, projectSlug);
+
+  return useMutation<
+    MetricsExtractionRule[],
+    RequestError,
+    {metricsExtractionRules: MetricsExtractionRule[]},
+    {previous?: MetricsExtractionRule[]}
+  >(
+    data => {
+      return api.requestPromise(queryKey[0], {
+        method: 'POST',
+        data,
+      });
+    },
+    {
+      // TODO: Implement optimistic updates
+      onSettled: () => {
+        queryClient.invalidateQueries(queryKey);
+      },
+    }
+  );
+}
+
+export function useUpdateMetricsExtractionRules(orgSlug: string, projectSlug: string) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+  const queryKey = getMetricsExtractionRulesEndpoint(orgSlug, projectSlug);
+
+  return useMutation<
+    MetricsExtractionRule[],
+    RequestError,
+    {metricsExtractionRules: MetricsExtractionRule[]},
+    {previous?: MetricsExtractionRule[]}
+  >(
+    data => {
+      return api.requestPromise(queryKey[0], {
+        method: 'PUT',
+        data,
+      });
+    },
+    {
+      onMutate: data => {
+        queryClient.cancelQueries(queryKey);
+
+        const previous = getApiQueryData<MetricsExtractionRule[]>(queryClient, queryKey);
+
+        const updatedRules = data.metricsExtractionRules;
+        const updatedRulesMap = new Map(
+          updatedRules.map(rule => [getRuleIdentifier(rule), rule])
+        );
+
+        setApiQueryData<MetricsExtractionRule[]>(queryClient, queryKey, oldRules => {
+          return oldRules?.map(rule => {
+            const updatedRule = updatedRulesMap.get(getRuleIdentifier(rule));
+            return updatedRule ?? rule;
+          });
+        });
+
+        return {previous};
+      },
+      onError: (_error, _variables, context) => {
+        if (context?.previous) {
+          setApiQueryData<MetricsExtractionRule[]>(
+            queryClient,
+            queryKey,
+            context.previous
+          );
+        }
+      },
+      onSettled: () => {
+        queryClient.invalidateQueries(queryKey);
+      },
+    }
+  );
+}