123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- 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<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 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: [<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 EMPTY_SET = new Set<never>();
- 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<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 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<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('Measure')}
- help={tct(
- 'Define the span attribute you want to track. Learn how to instrument custom attributes in [link:our docs].',
- {
- // TODO(telemetry-experience): add the correct link here once we have it!!!
- link: (
- <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
- ),
- }
- )}
- placeholder={t('Select a span 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={tct(
- 'The type of the metric determines which aggregation functions are available and what types of values it can store. For more information, read [link:our docs]',
- {
- // TODO(telemetry-experience): add the correct link here once we have it!!!
- link: (
- <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
- ),
- }
- )}
- />
- <SelectField
- name="tags"
- options={tagOptions}
- label={t('Group and filter by')}
- multiple
- placeholder={t('Select tags')}
- help={t('Select the tags that can be used to group and filter the metric.')}
- 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('Queries')}
- help={t(
- 'Define queries to narrow down the metric extraction to a specific set of spans.'
- )}
- 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
- {...SPAN_SEARCH_CONFIG}
- 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}
- onBlur={(queryString: string) =>
- onChange(conditions.toSpliced(index, 1, queryString), {})
- }
- />
- </SearchWrapper>
- {value.length > 1 && (
- <Button
- aria-label={t('Remove Query')}
- onClick={() => onChange(conditions.toSpliced(index, 1), {})}
- icon={<IconClose />}
- />
- )}
- </Fragment>
- ))}
- </ConditionsWrapper>
- <ConditionsButtonBar>
- <Button
- onClick={() => onChange([...conditions, ''], {})}
- icon={<IconAdd />}
- >
- {t('Add Query')}
- </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)};
- `;
|