metricsExtractionRuleForm.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import SearchBar from 'sentry/components/events/searchBar';
  5. import SelectField from 'sentry/components/forms/fields/selectField';
  6. import Form, {type FormProps} from 'sentry/components/forms/form';
  7. import FormField from 'sentry/components/forms/formField';
  8. import type FormModel from 'sentry/components/forms/model';
  9. import {IconAdd, IconClose} from 'sentry/icons';
  10. import {t} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import type {MetricType} from 'sentry/types/metrics';
  13. import type {Project} from 'sentry/types/project';
  14. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  15. import useOrganization from 'sentry/utils/useOrganization';
  16. import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
  17. export interface FormData {
  18. conditions: string[];
  19. spanAttribute: string | null;
  20. tags: string[];
  21. type: MetricType | null;
  22. }
  23. interface Props extends Omit<FormProps, 'onSubmit'> {
  24. initialData: FormData;
  25. project: Project;
  26. isEdit?: boolean;
  27. onSubmit?: (
  28. data: FormData,
  29. onSubmitSuccess: (data: FormData) => void,
  30. onSubmitError: (error: any) => void,
  31. event: React.FormEvent,
  32. model: FormModel
  33. ) => void;
  34. }
  35. const ListItemDetails = styled('span')`
  36. color: ${p => p.theme.subText};
  37. font-size: ${p => p.theme.fontSizeSmall};
  38. text-align: right;
  39. line-height: 1.2;
  40. `;
  41. const TYPE_OPTIONS = [
  42. {
  43. label: t('Counter'),
  44. value: 'c',
  45. trailingItems: [<ListItemDetails key="aggregates">{t('count')}</ListItemDetails>],
  46. },
  47. {
  48. label: t('Set'),
  49. value: 's',
  50. trailingItems: [
  51. <ListItemDetails key="aggregates">{t('count_unique')}</ListItemDetails>,
  52. ],
  53. },
  54. {
  55. label: t('Distribution'),
  56. value: 'd',
  57. trailingItems: [
  58. <ListItemDetails key="aggregates">
  59. {t('count, avg, sum, min, max, percentiles')}
  60. </ListItemDetails>,
  61. ],
  62. },
  63. ];
  64. export function MetricsExtractionRuleForm({isEdit, project, onSubmit, ...props}: Props) {
  65. const [customAttributes, setCustomeAttributes] = useState<string[]>(() => {
  66. const {spanAttribute, tags} = props.initialData;
  67. return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)];
  68. });
  69. const organization = useOrganization();
  70. const tags = useSpanFieldSupportedTags({projects: [parseInt(project.id, 10)]});
  71. // TODO(aknaus): Make this nicer
  72. const supportedTags = useMemo(() => {
  73. const copy = {...tags};
  74. delete copy.has;
  75. return copy;
  76. }, [tags]);
  77. const attributeOptions = useMemo(() => {
  78. let keys = Object.keys(supportedTags);
  79. if (customAttributes.length) {
  80. keys = [...new Set(keys.concat(customAttributes))];
  81. }
  82. return keys
  83. .map(key => ({
  84. label: key,
  85. value: key,
  86. }))
  87. .sort((a, b) => a.label.localeCompare(b.label));
  88. }, [customAttributes, supportedTags]);
  89. const handleSubmit = useCallback(
  90. (
  91. data: Record<string, any>,
  92. onSubmitSuccess: (data: Record<string, any>) => void,
  93. onSubmitError: (error: any) => void,
  94. event: React.FormEvent,
  95. model: FormModel
  96. ) => {
  97. onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model);
  98. },
  99. [onSubmit]
  100. );
  101. return (
  102. <Form onSubmit={onSubmit && handleSubmit} {...props}>
  103. {({model}) => (
  104. <Fragment>
  105. <SelectField
  106. name="spanAttribute"
  107. options={attributeOptions}
  108. disabled={isEdit}
  109. label={t('Span Attribute')}
  110. help={t('The span attribute to extract the metric from.')}
  111. placeholder={t('Select an attribute')}
  112. creatable
  113. formatCreateLabel={value => `Custom: "${value}"`}
  114. onCreateOption={value => {
  115. setCustomeAttributes(curr => [...curr, value]);
  116. model.setValue('spanAttribute', value);
  117. }}
  118. required
  119. />
  120. <SelectField
  121. name="type"
  122. disabled={isEdit}
  123. options={TYPE_OPTIONS}
  124. label={t('Type')}
  125. help={t(
  126. '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'
  127. )}
  128. />
  129. <SelectField
  130. name="tags"
  131. options={attributeOptions}
  132. label={t('Tags')}
  133. multiple
  134. placeholder={t('Select tags')}
  135. help={t(
  136. 'Those tags will be stored with the metric. They can be used to filter and group the metric in the UI.'
  137. )}
  138. creatable
  139. formatCreateLabel={value => `Custom: "${value}"`}
  140. onCreateOption={value => {
  141. setCustomeAttributes(curr => [...curr, value]);
  142. const currentTags = model.getValue('tags') as string[];
  143. model.setValue('tags', [...currentTags, value]);
  144. }}
  145. />
  146. <FormField
  147. label={t('Filters')}
  148. help={t(
  149. 'Define filters for spans. The metric will be extracted only from spans that match these conditions.'
  150. )}
  151. name="conditions"
  152. inline={false}
  153. hasControlState={false}
  154. flexibleControlStateSize
  155. >
  156. {({onChange, initialData, value}) => {
  157. const conditions = (value || initialData) as string[];
  158. return (
  159. <Fragment>
  160. <ConditionsWrapper hasDelete={value.length > 1}>
  161. {conditions.map((query, index) => (
  162. <Fragment key={index}>
  163. <SearchWrapper hasPrefix={index !== 0}>
  164. {index !== 0 && <ConditionLetter>{t('or')}</ConditionLetter>}
  165. <SearchBar
  166. searchSource="metrics-extraction"
  167. query={query}
  168. onSearch={(queryString: string) =>
  169. onChange(conditions.toSpliced(index, 1, queryString), {})
  170. }
  171. placeholder={t('Search for span attributes')}
  172. organization={organization}
  173. metricAlert={false}
  174. supportedTags={supportedTags}
  175. dataset={DiscoverDatasets.SPANS_INDEXED}
  176. projectIds={[parseInt(project.id, 10)]}
  177. hasRecentSearches={false}
  178. onBlur={(queryString: string) =>
  179. onChange(conditions.toSpliced(index, 1, queryString), {})
  180. }
  181. />
  182. </SearchWrapper>
  183. {value.length > 1 && (
  184. <Button
  185. aria-label={t('Remove Condition')}
  186. onClick={() => onChange(conditions.toSpliced(index, 1), {})}
  187. icon={<IconClose />}
  188. />
  189. )}
  190. </Fragment>
  191. ))}
  192. </ConditionsWrapper>
  193. <ConditionsButtonBar>
  194. <Button
  195. onClick={() => onChange([...conditions, ''], {})}
  196. icon={<IconAdd />}
  197. >
  198. {t('Add condition')}
  199. </Button>
  200. </ConditionsButtonBar>
  201. </Fragment>
  202. );
  203. }}
  204. </FormField>
  205. </Fragment>
  206. )}
  207. </Form>
  208. );
  209. }
  210. const ConditionsWrapper = styled('div')<{hasDelete: boolean}>`
  211. display: grid;
  212. gap: ${space(1)};
  213. ${p =>
  214. p.hasDelete
  215. ? `
  216. grid-template-columns: 1fr min-content;
  217. `
  218. : `
  219. grid-template-columns: 1fr;
  220. `}
  221. `;
  222. const SearchWrapper = styled('div')<{hasPrefix: boolean}>`
  223. display: grid;
  224. gap: ${space(1)};
  225. ${p =>
  226. p.hasPrefix
  227. ? `
  228. grid-template-columns: max-content 1fr;
  229. `
  230. : `
  231. grid-template-columns: 1fr;
  232. `}
  233. `;
  234. const ConditionLetter = styled('div')`
  235. background-color: ${p => p.theme.purple100};
  236. border-radius: ${p => p.theme.borderRadius};
  237. text-align: center;
  238. padding: 0 ${space(2)};
  239. color: ${p => p.theme.purple400};
  240. white-space: nowrap;
  241. font-weight: ${p => p.theme.fontWeightBold};
  242. align-content: center;
  243. `;
  244. const ConditionsButtonBar = styled('div')`
  245. margin-top: ${space(1)};
  246. `;