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 ExternalLink from 'sentry/components/links/externalLink'; import {IconAdd, IconClose} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {MetricAggregation, MetricsExtractionCondition} 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 {SpanIndexedField} from 'sentry/views/insights/types'; import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags'; import {useMetricsExtractionRules} from 'sentry/views/settings/projectMetrics/utils/api'; export type AggregateGroup = 'count' | 'count_unique' | 'min_max' | 'percentiles'; export interface FormData { aggregates: AggregateGroup[]; conditions: MetricsExtractionCondition[]; spanAttribute: string | null; tags: string[]; } interface Props extends Omit { initialData: FormData; project: Project; isEdit?: boolean; onSubmit?: ( data: FormData, onSubmitSuccess: (data: FormData) => void, onSubmitError: (error: any) => void, event: React.FormEvent, model: FormModel ) => void; } const KNOWN_NUMERIC_FIELDS = new Set([ SpanIndexedField.SPAN_DURATION, SpanIndexedField.SPAN_SELF_TIME, SpanIndexedField.PROJECT_ID, SpanIndexedField.INP, SpanIndexedField.INP_SCORE, SpanIndexedField.INP_SCORE_WEIGHT, SpanIndexedField.TOTAL_SCORE, SpanIndexedField.CACHE_ITEM_SIZE, SpanIndexedField.MESSAGING_MESSAGE_BODY_SIZE, SpanIndexedField.MESSAGING_MESSAGE_RECEIVE_LATENCY, SpanIndexedField.MESSAGING_MESSAGE_RETRY_COUNT, ]); const AGGREGATE_OPTIONS: {label: string; value: AggregateGroup}[] = [ { label: t('count'), value: 'count', }, { label: t('count_unique'), value: 'count_unique', }, { label: t('min, max, sum, avg'), value: 'min_max', }, { label: t('percentiles'), value: 'percentiles', }, ]; export function explodeAggregateGroup(group: AggregateGroup): MetricAggregation[] { switch (group) { case 'count': return ['count']; case 'count_unique': return ['count_unique']; case 'min_max': return ['min', 'max', 'sum', 'avg']; case 'percentiles': return ['p50', 'p75', 'p95', 'p99']; default: throw new Error(`Unknown aggregate group: ${group}`); } } export function aggregatesToGroups(aggregates: MetricAggregation[]): AggregateGroup[] { const groups: AggregateGroup[] = []; if (aggregates.includes('count')) { groups.push('count'); } if (aggregates.includes('count_unique')) { groups.push('count_unique'); } const minMaxAggregates = new Set(['min', 'max', 'sum', 'avg']); if (aggregates.find(aggregate => minMaxAggregates.has(aggregate))) { groups.push('min_max'); } const percentileAggregates = new Set(['p50', 'p75', 'p95', 'p99']); if (aggregates.find(aggregate => percentileAggregates.has(aggregate))) { groups.push('percentiles'); } return groups; } let currentTempId = 0; function createTempId(): number { currentTempId -= 1; return currentTempId; } export function createCondition(): MetricsExtractionCondition { return { value: '', // id and mris will be set by the backend after creation id: createTempId(), mris: [], }; } const EMPTY_SET = new Set(); const SPAN_SEARCH_CONFIG = { booleanKeys: EMPTY_SET, dateKeys: EMPTY_SET, durationKeys: EMPTY_SET, numericKeys: EMPTY_SET, percentageKeys: EMPTY_SET, sizeKeys: EMPTY_SET, textOperatorKeys: EMPTY_SET, disallowFreeText: true, disallowWildcard: true, disallowNegation: true, }; export function MetricsExtractionRuleForm({isEdit, project, onSubmit, ...props}: Props) { const organization = useOrganization(); const [customAttributes, setCustomeAttributes] = useState(() => { const {spanAttribute, tags} = props.initialData; return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)]; }); const {data: extractionRules} = useMetricsExtractionRules( organization.slug, project.slug ); 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); const disabledKeys = new Set(extractionRules?.map(rule => rule.spanAttribute) || []); if (customAttributes.length) { keys = [...new Set(keys.concat(customAttributes))]; } return ( keys .map(key => ({ label: key, value: key, disabled: disabledKeys.has(key), tooltip: disabledKeys.has(key) ? t( 'This attribute is already in use. Please select another one or edit the existing metric.' ) : undefined, tooltipOptions: {position: 'left'}, })) .sort((a, b) => a.label.localeCompare(b.label)) // Sort disabled attributes to bottom .sort((a, b) => Number(a.disabled) - Number(b.disabled)) ); }, [customAttributes, supportedTags, extractionRules]); const tagOptions = useMemo(() => { return attributeOptions.filter( // We don't want to suggest numeric fields as tags as they would explode cardinality option => !KNOWN_NUMERIC_FIELDS.has(option.value as SpanIndexedField) ); }, [attributeOptions]); const handleSubmit = useCallback( ( data: Record, onSubmitSuccess: (data: Record) => void, onSubmitError: (error: any) => void, event: React.FormEvent, model: FormModel ) => { onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model); }, [onSubmit] ); return (
{({model}) => ( ), } )} placeholder={t('Select a span attribute')} creatable formatCreateLabel={value => `Custom: "${value}"`} onCreateOption={value => { setCustomeAttributes(curr => [...curr, value]); model.setValue('spanAttribute', value); }} required /> ), } )} /> `Custom: "${value}"`} onCreateOption={value => { setCustomeAttributes(curr => [...curr, value]); const currentTags = model.getValue('tags') as string[]; model.setValue('tags', [...currentTags, value]); }} /> {({onChange, initialData, value}) => { const conditions = (value || initialData || []) as MetricsExtractionCondition[]; const handleChange = (queryString: string, index: number) => { onChange( conditions.toSpliced(index, 1, { ...conditions[index], value: queryString, }), {} ); }; return ( 1}> {conditions.map((condition, index) => ( 1}> {conditions.length > 1 && ( {index + 1} )} handleChange(queryString, index) } placeholder={t('Search for span attributes')} organization={organization} metricAlert={false} supportedTags={supportedTags} dataset={DiscoverDatasets.SPANS_INDEXED} projectIds={[parseInt(project.id, 10)]} hasRecentSearches={false} onBlur={(queryString: string) => handleChange(queryString, index) } savedSearchType={undefined} useFormWrapper={false} /> {value.length > 1 && ( ); }} )}
); } const ConditionsWrapper = styled('div')<{hasDelete: boolean}>` padding: ${space(1)} 0; display: grid; align-items: center; 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)}; align-items: center; ${p => p.hasPrefix ? ` grid-template-columns: max-content 1fr; ` : ` grid-template-columns: 1fr; `} `; const ConditionSymbol = styled('div')` background-color: ${p => p.theme.purple100}; color: ${p => p.theme.purple400}; text-align: center; align-content: center; height: ${space(3)}; width: ${space(3)}; border-radius: 50%; `; const ConditionsButtonBar = styled('div')` margin-top: ${space(1)}; height: ${space(3)}; `;