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 {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 {SpanIndexedField} from 'sentry/views/insights/types'; 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 { 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 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 TYPE_OPTIONS = [ { label: t('Counter'), value: 'c', trailingItems: [{t('count')}], }, { label: t('Set'), value: 's', trailingItems: [ {t('count_unique')}, ], }, { label: t('Distribution'), value: 'd', trailingItems: [ {t('count, avg, sum, min, max, percentiles')} , ], }, ]; 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 [customAttributes, setCustomeAttributes] = useState(() => { 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 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 string[]; return ( 1}> {conditions.map((query, index) => ( {index !== 0 && {t('or')}} 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} onBlur={(queryString: string) => onChange(conditions.toSpliced(index, 1, queryString), {}) } /> {value.length > 1 && ( ); }} )}
); } 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)}; `;