Browse Source

feat(metrics): Extraction rules create form hardcoded (#72840)

Add an initial draft of the metrics extraction rules create form.
Values in the select field are hardcoded.

Relates to https://github.com/getsentry/sentry/issues/72679
ArthurKnaus 8 months ago
parent
commit
aeb8a7bf98

+ 2 - 2
static/app/components/modals/metricWidgetViewerModal/queries.tsx

@@ -99,7 +99,7 @@ export const Queries = memo(function Queries({
   return (
     <ExpressionsWrapper>
       {metricQueries.map((query, index) => (
-        <ExpressionWrapper key={index}>
+        <ExpressionWrapper key={query.id}>
           {showQuerySymbols && (
             <QueryToggle
               isHidden={query.isHidden}
@@ -143,7 +143,7 @@ export const Queries = memo(function Queries({
         </ExpressionWrapper>
       ))}
       {metricEquations.map((equation, index) => (
-        <ExpressionWrapper key={index}>
+        <ExpressionWrapper key={equation.id}>
           {showQuerySymbols && (
             <QueryToggle
               isHidden={equation.isHidden}

+ 7 - 0
static/app/routes.tsx

@@ -534,6 +534,13 @@ function buildRoutes() {
         <IndexRoute
           component={make(() => import('sentry/views/settings/projectMetrics'))}
         />
+        <Route
+          name={t('Extract Metric')}
+          path="extract-metric/"
+          component={make(
+            () => import('sentry/views/settings/projectMetrics/extractMetric')
+          )}
+        />
         <Route
           name={t('Metrics Details')}
           path=":mri/"

+ 4 - 0
static/app/utils/metrics/features.tsx

@@ -20,6 +20,10 @@ export function hasMetricAlertFeature(organization: Organization) {
   return organization.features.includes('incidents');
 }
 
+export function hasCustomMetricsExtractionRules(organization: Organization) {
+  return organization.features.includes('custom-metrics-extraction-rule');
+}
+
 /**
  * Returns the forceMetricsLayer query param for the alert
  * wrapped in an object so it can be spread into existing query params

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

@@ -128,6 +128,34 @@ 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',

+ 159 - 0
static/app/views/settings/projectMetrics/extractMetric.tsx

@@ -0,0 +1,159 @@
+import {Fragment, useState} from 'react';
+
+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;
+}
+
+const INITIAL_DATA: FormData = {
+  spanAttribute: null,
+  metricType: 'c',
+  tags: [],
+  unit: 'none',
+};
+
+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();
+
+  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)} />
+      <SettingsPageHeader title={PAGE_TITLE} />
+      <TextBlock>
+        {t(
+          'Metric Extraction Rules enable you to derive meaningful metrics from the attributes present on spans within your application.'
+        )}
+      </TextBlock>
+      <TextBlock>
+        {t(
+          "By defining these rules, you can specify how and which attributes should be processed to generate useful metrics that provide detailed insights into your application's performance and behavior."
+        )}
+      </TextBlock>
+      <Panel>
+        <PanelHeader>{t('Create Extraction Rule')}</PanelHeader>
+        <PanelBody>
+          <Form
+            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>
+  );
+}
+
+export default ExtractMetric;

+ 59 - 1
static/app/views/settings/projectMetrics/projectMetrics.tsx

@@ -5,7 +5,7 @@ import * as Sentry from '@sentry/react';
 import debounce from 'lodash/debounce';
 
 import Tag from 'sentry/components/badge/tag';
-import {Button} from 'sentry/components/button';
+import {Button, LinkButton} from 'sentry/components/button';
 import ExternalLink from 'sentry/components/links/externalLink';
 import Link from 'sentry/components/links/link';
 import {PanelTable} from 'sentry/components/panels/panelTable';
@@ -24,6 +24,7 @@ import {
   DEFAULT_METRICS_CARDINALITY_LIMIT,
   METRICS_DOCS_URL,
 } from 'sentry/utils/metrics/constants';
+import {hasCustomMetricsExtractionRules} from 'sentry/utils/metrics/features';
 import {getReadableMetricType} from 'sentry/utils/metrics/formatters';
 import {formatMRI} from 'sentry/utils/metrics/mri';
 import {useBlockMetric} from 'sentry/utils/metrics/useBlockMetric';
@@ -33,6 +34,7 @@ import {decodeScalar} from 'sentry/utils/queryString';
 import routeTitleGen from 'sentry/utils/routeTitle';
 import {middleEllipsis} from 'sentry/utils/string/middleEllipsis';
 import {useNavigate} from 'sentry/utils/useNavigate';
+import useOrganization from 'sentry/utils/useOrganization';
 import {useMetricsOnboardingSidebar} from 'sentry/views/metrics/ddmOnboarding/useMetricsOnboardingSidebar';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
@@ -54,6 +56,7 @@ enum BlockingStatusTab {
 type MetricWithCardinality = MetricMeta & {cardinality: number};
 
 function ProjectMetrics({project, location}: Props) {
+  const organization = useOrganization();
   const metricsMeta = useMetricsMeta(
     {projects: [parseInt(project.id, 10)]},
     ['custom'],
@@ -114,6 +117,8 @@ function ProjectMetrics({project, location}: Props) {
   const {activateSidebar} = useMetricsOnboardingSidebar();
   const [selectedTab, setSelectedTab] = useState(BlockingStatusTab.ACTIVE);
 
+  const hasExtractionRules = hasCustomMetricsExtractionRules(organization);
+
   return (
     <Fragment>
       <SentryDocumentTitle title={routeTitleGen(t('Metrics'), project.slug, false)} />
@@ -150,6 +155,22 @@ function ProjectMetrics({project, location}: Props) {
 
       <CardinalityLimit project={project} />
 
+      {hasExtractionRules && (
+        <Fragment>
+          <ExtractionRulesSearchWrapper>
+            <h6>{t('Metric Extraction Rules')}</h6>
+            <LinkButton
+              to={`/settings/projects/${project.slug}/metrics/extract-metric`}
+              priority="primary"
+              size="sm"
+            >
+              {t('Add Extraction Rule')}
+            </LinkButton>
+          </ExtractionRulesSearchWrapper>
+          <MetricsExtractionTable isLoading={false} extractionRules={[]} />
+        </Fragment>
+      )}
+
       <SearchWrapper>
         <h6>{t('Emitted Metrics')}</h6>
         <SearchBar
@@ -190,6 +211,39 @@ function ProjectMetrics({project, location}: Props) {
   );
 }
 
+interface MetricsExtractionTableProps {
+  extractionRules: never[];
+  isLoading: boolean;
+}
+
+function MetricsExtractionTable({
+  extractionRules,
+  isLoading,
+}: MetricsExtractionTableProps) {
+  return (
+    <StyledPanelTable
+      headers={[
+        t('Span attribute'),
+        <Cell right key="type">
+          {t('Type')}
+        </Cell>,
+        <Cell right key="unit">
+          {t('Unit')}
+        </Cell>,
+        <Cell right key="tags">
+          {t('Tags')}
+        </Cell>,
+        <Cell right key="actions">
+          {t('Actions')}
+        </Cell>,
+      ]}
+      emptyMessage={t('You have not created any extraction rules yet.')}
+      isEmpty={extractionRules.length === 0}
+      isLoading={isLoading}
+    />
+  );
+}
+
 interface MetricsTableProps {
   isLoading: boolean;
   metrics: MetricWithCardinality[];
@@ -301,6 +355,10 @@ const SearchWrapper = styled('div')`
   }
 `;
 
+const ExtractionRulesSearchWrapper = styled(SearchWrapper)`
+  margin-bottom: ${space(1)};
+`;
+
 const StyledPanelTable = styled(PanelTable)`
   grid-template-columns: 1fr repeat(4, min-content);
 `;