metricsExtractionRuleForm.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  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 ExternalLink from 'sentry/components/links/externalLink';
  10. import {IconAdd, IconClose} from 'sentry/icons';
  11. import {t, tct} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {MetricType} from 'sentry/types/metrics';
  14. import type {Project} from 'sentry/types/project';
  15. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  16. import useOrganization from 'sentry/utils/useOrganization';
  17. import {SpanIndexedField} from 'sentry/views/insights/types';
  18. import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
  19. export interface FormData {
  20. conditions: string[];
  21. spanAttribute: string | null;
  22. tags: string[];
  23. type: MetricType | null;
  24. }
  25. interface Props extends Omit<FormProps, 'onSubmit'> {
  26. initialData: FormData;
  27. project: Project;
  28. isEdit?: boolean;
  29. onSubmit?: (
  30. data: FormData,
  31. onSubmitSuccess: (data: FormData) => void,
  32. onSubmitError: (error: any) => void,
  33. event: React.FormEvent,
  34. model: FormModel
  35. ) => void;
  36. }
  37. const ListItemDetails = styled('span')`
  38. color: ${p => p.theme.subText};
  39. font-size: ${p => p.theme.fontSizeSmall};
  40. text-align: right;
  41. line-height: 1.2;
  42. `;
  43. const KNOWN_NUMERIC_FIELDS = new Set([
  44. SpanIndexedField.SPAN_DURATION,
  45. SpanIndexedField.SPAN_SELF_TIME,
  46. SpanIndexedField.PROJECT_ID,
  47. SpanIndexedField.INP,
  48. SpanIndexedField.INP_SCORE,
  49. SpanIndexedField.INP_SCORE_WEIGHT,
  50. SpanIndexedField.TOTAL_SCORE,
  51. SpanIndexedField.CACHE_ITEM_SIZE,
  52. SpanIndexedField.MESSAGING_MESSAGE_BODY_SIZE,
  53. SpanIndexedField.MESSAGING_MESSAGE_RECEIVE_LATENCY,
  54. SpanIndexedField.MESSAGING_MESSAGE_RETRY_COUNT,
  55. ]);
  56. const TYPE_OPTIONS = [
  57. {
  58. label: t('Counter'),
  59. value: 'c',
  60. trailingItems: [<ListItemDetails key="aggregates">{t('count')}</ListItemDetails>],
  61. },
  62. {
  63. label: t('Set'),
  64. value: 's',
  65. trailingItems: [
  66. <ListItemDetails key="aggregates">{t('count_unique')}</ListItemDetails>,
  67. ],
  68. },
  69. {
  70. label: t('Distribution'),
  71. value: 'd',
  72. trailingItems: [
  73. <ListItemDetails key="aggregates">
  74. {t('count, avg, sum, min, max, percentiles')}
  75. </ListItemDetails>,
  76. ],
  77. },
  78. ];
  79. const EMPTY_SET = new Set<never>();
  80. const SPAN_SEARCH_CONFIG = {
  81. booleanKeys: EMPTY_SET,
  82. dateKeys: EMPTY_SET,
  83. durationKeys: EMPTY_SET,
  84. numericKeys: EMPTY_SET,
  85. percentageKeys: EMPTY_SET,
  86. sizeKeys: EMPTY_SET,
  87. textOperatorKeys: EMPTY_SET,
  88. disallowFreeText: true,
  89. disallowWildcard: true,
  90. disallowNegation: true,
  91. };
  92. export function MetricsExtractionRuleForm({isEdit, project, onSubmit, ...props}: Props) {
  93. const [customAttributes, setCustomeAttributes] = useState<string[]>(() => {
  94. const {spanAttribute, tags} = props.initialData;
  95. return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)];
  96. });
  97. const organization = useOrganization();
  98. const tags = useSpanFieldSupportedTags({projects: [parseInt(project.id, 10)]});
  99. // TODO(aknaus): Make this nicer
  100. const supportedTags = useMemo(() => {
  101. const copy = {...tags};
  102. delete copy.has;
  103. return copy;
  104. }, [tags]);
  105. const attributeOptions = useMemo(() => {
  106. let keys = Object.keys(supportedTags);
  107. if (customAttributes.length) {
  108. keys = [...new Set(keys.concat(customAttributes))];
  109. }
  110. return keys
  111. .map(key => ({
  112. label: key,
  113. value: key,
  114. }))
  115. .sort((a, b) => a.label.localeCompare(b.label));
  116. }, [customAttributes, supportedTags]);
  117. const tagOptions = useMemo(() => {
  118. return attributeOptions.filter(
  119. // We don't want to suggest numeric fields as tags as they would explode cardinality
  120. option => !KNOWN_NUMERIC_FIELDS.has(option.value as SpanIndexedField)
  121. );
  122. }, [attributeOptions]);
  123. const handleSubmit = useCallback(
  124. (
  125. data: Record<string, any>,
  126. onSubmitSuccess: (data: Record<string, any>) => void,
  127. onSubmitError: (error: any) => void,
  128. event: React.FormEvent,
  129. model: FormModel
  130. ) => {
  131. onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model);
  132. },
  133. [onSubmit]
  134. );
  135. return (
  136. <Form onSubmit={onSubmit && handleSubmit} {...props}>
  137. {({model}) => (
  138. <Fragment>
  139. <SelectField
  140. name="spanAttribute"
  141. options={attributeOptions}
  142. disabled={isEdit}
  143. label={t('Measure')}
  144. help={tct(
  145. 'Define the span attribute you want to track. Learn how to instrument custom attributes in [link:our docs].',
  146. {
  147. // TODO(telemetry-experience): add the correct link here once we have it!!!
  148. link: (
  149. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  150. ),
  151. }
  152. )}
  153. placeholder={t('Select a span attribute')}
  154. creatable
  155. formatCreateLabel={value => `Custom: "${value}"`}
  156. onCreateOption={value => {
  157. setCustomeAttributes(curr => [...curr, value]);
  158. model.setValue('spanAttribute', value);
  159. }}
  160. required
  161. />
  162. <SelectField
  163. name="type"
  164. disabled={isEdit}
  165. options={TYPE_OPTIONS}
  166. label={t('Type')}
  167. help={tct(
  168. '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]',
  169. {
  170. // TODO(telemetry-experience): add the correct link here once we have it!!!
  171. link: (
  172. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  173. ),
  174. }
  175. )}
  176. />
  177. <SelectField
  178. name="tags"
  179. options={tagOptions}
  180. label={t('Group and filter by')}
  181. multiple
  182. placeholder={t('Select tags')}
  183. help={t('Select the tags that can be used to group and filter the metric.')}
  184. creatable
  185. formatCreateLabel={value => `Custom: "${value}"`}
  186. onCreateOption={value => {
  187. setCustomeAttributes(curr => [...curr, value]);
  188. const currentTags = model.getValue('tags') as string[];
  189. model.setValue('tags', [...currentTags, value]);
  190. }}
  191. />
  192. <FormField
  193. label={t('Queries')}
  194. help={t(
  195. 'Define queries to narrow down the metric extraction to a specific set of spans.'
  196. )}
  197. name="conditions"
  198. inline={false}
  199. hasControlState={false}
  200. flexibleControlStateSize
  201. >
  202. {({onChange, initialData, value}) => {
  203. const conditions = (value || initialData) as string[];
  204. return (
  205. <Fragment>
  206. <ConditionsWrapper hasDelete={value.length > 1}>
  207. {conditions.map((query, index) => (
  208. <Fragment key={index}>
  209. <SearchWrapper hasPrefix={index !== 0}>
  210. {index !== 0 && <ConditionLetter>{t('or')}</ConditionLetter>}
  211. <SearchBar
  212. {...SPAN_SEARCH_CONFIG}
  213. searchSource="metrics-extraction"
  214. query={query}
  215. onSearch={(queryString: string) =>
  216. onChange(conditions.toSpliced(index, 1, queryString), {})
  217. }
  218. placeholder={t('Search for span attributes')}
  219. organization={organization}
  220. metricAlert={false}
  221. supportedTags={supportedTags}
  222. dataset={DiscoverDatasets.SPANS_INDEXED}
  223. projectIds={[parseInt(project.id, 10)]}
  224. hasRecentSearches={false}
  225. onBlur={(queryString: string) =>
  226. onChange(conditions.toSpliced(index, 1, queryString), {})
  227. }
  228. />
  229. </SearchWrapper>
  230. {value.length > 1 && (
  231. <Button
  232. aria-label={t('Remove Query')}
  233. onClick={() => onChange(conditions.toSpliced(index, 1), {})}
  234. icon={<IconClose />}
  235. />
  236. )}
  237. </Fragment>
  238. ))}
  239. </ConditionsWrapper>
  240. <ConditionsButtonBar>
  241. <Button
  242. onClick={() => onChange([...conditions, ''], {})}
  243. icon={<IconAdd />}
  244. >
  245. {t('Add Query')}
  246. </Button>
  247. </ConditionsButtonBar>
  248. </Fragment>
  249. );
  250. }}
  251. </FormField>
  252. </Fragment>
  253. )}
  254. </Form>
  255. );
  256. }
  257. const ConditionsWrapper = styled('div')<{hasDelete: boolean}>`
  258. display: grid;
  259. gap: ${space(1)};
  260. ${p =>
  261. p.hasDelete
  262. ? `
  263. grid-template-columns: 1fr min-content;
  264. `
  265. : `
  266. grid-template-columns: 1fr;
  267. `}
  268. `;
  269. const SearchWrapper = styled('div')<{hasPrefix: boolean}>`
  270. display: grid;
  271. gap: ${space(1)};
  272. ${p =>
  273. p.hasPrefix
  274. ? `
  275. grid-template-columns: max-content 1fr;
  276. `
  277. : `
  278. grid-template-columns: 1fr;
  279. `}
  280. `;
  281. const ConditionLetter = styled('div')`
  282. background-color: ${p => p.theme.purple100};
  283. border-radius: ${p => p.theme.borderRadius};
  284. text-align: center;
  285. padding: 0 ${space(2)};
  286. color: ${p => p.theme.purple400};
  287. white-space: nowrap;
  288. font-weight: ${p => p.theme.fontWeightBold};
  289. align-content: center;
  290. `;
  291. const ConditionsButtonBar = styled('div')`
  292. margin-top: ${space(1)};
  293. `;