metricsExtractionRuleForm.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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 {MetricAggregation, MetricsExtractionCondition} 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. import {useMetricsExtractionRules} from 'sentry/views/settings/projectMetrics/utils/api';
  20. export type AggregateGroup = 'count' | 'count_unique' | 'min_max' | 'percentiles';
  21. export interface FormData {
  22. aggregates: AggregateGroup[];
  23. conditions: MetricsExtractionCondition[];
  24. spanAttribute: string | null;
  25. tags: string[];
  26. }
  27. interface Props extends Omit<FormProps, 'onSubmit'> {
  28. initialData: FormData;
  29. project: Project;
  30. isEdit?: boolean;
  31. onSubmit?: (
  32. data: FormData,
  33. onSubmitSuccess: (data: FormData) => void,
  34. onSubmitError: (error: any) => void,
  35. event: React.FormEvent,
  36. model: FormModel
  37. ) => void;
  38. }
  39. const KNOWN_NUMERIC_FIELDS = new Set([
  40. SpanIndexedField.SPAN_DURATION,
  41. SpanIndexedField.SPAN_SELF_TIME,
  42. SpanIndexedField.PROJECT_ID,
  43. SpanIndexedField.INP,
  44. SpanIndexedField.INP_SCORE,
  45. SpanIndexedField.INP_SCORE_WEIGHT,
  46. SpanIndexedField.TOTAL_SCORE,
  47. SpanIndexedField.CACHE_ITEM_SIZE,
  48. SpanIndexedField.MESSAGING_MESSAGE_BODY_SIZE,
  49. SpanIndexedField.MESSAGING_MESSAGE_RECEIVE_LATENCY,
  50. SpanIndexedField.MESSAGING_MESSAGE_RETRY_COUNT,
  51. ]);
  52. const AGGREGATE_OPTIONS: {label: string; value: AggregateGroup}[] = [
  53. {
  54. label: t('count'),
  55. value: 'count',
  56. },
  57. {
  58. label: t('count_unique'),
  59. value: 'count_unique',
  60. },
  61. {
  62. label: t('min, max, sum, avg'),
  63. value: 'min_max',
  64. },
  65. {
  66. label: t('percentiles'),
  67. value: 'percentiles',
  68. },
  69. ];
  70. export function explodeAggregateGroup(group: AggregateGroup): MetricAggregation[] {
  71. switch (group) {
  72. case 'count':
  73. return ['count'];
  74. case 'count_unique':
  75. return ['count_unique'];
  76. case 'min_max':
  77. return ['min', 'max', 'sum', 'avg'];
  78. case 'percentiles':
  79. return ['p50', 'p75', 'p95', 'p99'];
  80. default:
  81. throw new Error(`Unknown aggregate group: ${group}`);
  82. }
  83. }
  84. export function aggregatesToGroups(aggregates: MetricAggregation[]): AggregateGroup[] {
  85. const groups: AggregateGroup[] = [];
  86. if (aggregates.includes('count')) {
  87. groups.push('count');
  88. }
  89. if (aggregates.includes('count_unique')) {
  90. groups.push('count_unique');
  91. }
  92. const minMaxAggregates = new Set<MetricAggregation>(['min', 'max', 'sum', 'avg']);
  93. if (aggregates.find(aggregate => minMaxAggregates.has(aggregate))) {
  94. groups.push('min_max');
  95. }
  96. const percentileAggregates = new Set<MetricAggregation>(['p50', 'p75', 'p95', 'p99']);
  97. if (aggregates.find(aggregate => percentileAggregates.has(aggregate))) {
  98. groups.push('percentiles');
  99. }
  100. return groups;
  101. }
  102. let currentTempId = 0;
  103. function createTempId(): number {
  104. currentTempId -= 1;
  105. return currentTempId;
  106. }
  107. export function createCondition(): MetricsExtractionCondition {
  108. return {
  109. value: '',
  110. // id and mris will be set by the backend after creation
  111. id: createTempId(),
  112. mris: [],
  113. };
  114. }
  115. const EMPTY_SET = new Set<never>();
  116. const SPAN_SEARCH_CONFIG = {
  117. booleanKeys: EMPTY_SET,
  118. dateKeys: EMPTY_SET,
  119. durationKeys: EMPTY_SET,
  120. numericKeys: EMPTY_SET,
  121. percentageKeys: EMPTY_SET,
  122. sizeKeys: EMPTY_SET,
  123. textOperatorKeys: EMPTY_SET,
  124. disallowFreeText: true,
  125. disallowWildcard: true,
  126. disallowNegation: true,
  127. };
  128. export function MetricsExtractionRuleForm({isEdit, project, onSubmit, ...props}: Props) {
  129. const organization = useOrganization();
  130. const [customAttributes, setCustomeAttributes] = useState<string[]>(() => {
  131. const {spanAttribute, tags} = props.initialData;
  132. return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)];
  133. });
  134. const {data: extractionRules} = useMetricsExtractionRules(
  135. organization.slug,
  136. project.slug
  137. );
  138. const tags = useSpanFieldSupportedTags({projects: [parseInt(project.id, 10)]});
  139. // TODO(aknaus): Make this nicer
  140. const supportedTags = useMemo(() => {
  141. const copy = {...tags};
  142. delete copy.has;
  143. return copy;
  144. }, [tags]);
  145. const attributeOptions = useMemo(() => {
  146. let keys = Object.keys(supportedTags);
  147. const disabledKeys = new Set(extractionRules?.map(rule => rule.spanAttribute) || []);
  148. if (customAttributes.length) {
  149. keys = [...new Set(keys.concat(customAttributes))];
  150. }
  151. return (
  152. keys
  153. .map(key => ({
  154. label: key,
  155. value: key,
  156. disabled: disabledKeys.has(key),
  157. tooltip: disabledKeys.has(key)
  158. ? t(
  159. 'This attribute is already in use. Please select another one or edit the existing metric.'
  160. )
  161. : undefined,
  162. tooltipOptions: {position: 'left'},
  163. }))
  164. .sort((a, b) => a.label.localeCompare(b.label))
  165. // Sort disabled attributes to bottom
  166. .sort((a, b) => Number(a.disabled) - Number(b.disabled))
  167. );
  168. }, [customAttributes, supportedTags, extractionRules]);
  169. const tagOptions = useMemo(() => {
  170. return attributeOptions.filter(
  171. // We don't want to suggest numeric fields as tags as they would explode cardinality
  172. option => !KNOWN_NUMERIC_FIELDS.has(option.value as SpanIndexedField)
  173. );
  174. }, [attributeOptions]);
  175. const handleSubmit = useCallback(
  176. (
  177. data: Record<string, any>,
  178. onSubmitSuccess: (data: Record<string, any>) => void,
  179. onSubmitError: (error: any) => void,
  180. event: React.FormEvent,
  181. model: FormModel
  182. ) => {
  183. onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model);
  184. },
  185. [onSubmit]
  186. );
  187. return (
  188. <Form onSubmit={onSubmit && handleSubmit} {...props}>
  189. {({model}) => (
  190. <Fragment>
  191. <SelectField
  192. name="spanAttribute"
  193. options={attributeOptions}
  194. disabled={isEdit}
  195. label={t('Measure')}
  196. help={tct(
  197. 'Define the span attribute you want to track. Learn how to instrument custom attributes in [link:our docs].',
  198. {
  199. // TODO(telemetry-experience): add the correct link here once we have it!!!
  200. link: (
  201. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  202. ),
  203. }
  204. )}
  205. placeholder={t('Select a span attribute')}
  206. creatable
  207. formatCreateLabel={value => `Custom: "${value}"`}
  208. onCreateOption={value => {
  209. setCustomeAttributes(curr => [...curr, value]);
  210. model.setValue('spanAttribute', value);
  211. }}
  212. required
  213. />
  214. <SelectField
  215. name="aggregates"
  216. required
  217. options={AGGREGATE_OPTIONS}
  218. label={t('Aggregate')}
  219. multiple
  220. help={tct(
  221. 'Select the aggregations you want to store. For more information, read [link:our docs]',
  222. {
  223. // TODO(telemetry-experience): add the correct link here once we have it!!!
  224. link: (
  225. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  226. ),
  227. }
  228. )}
  229. />
  230. <SelectField
  231. name="tags"
  232. options={tagOptions}
  233. label={t('Group and filter by')}
  234. multiple
  235. placeholder={t('Select tags')}
  236. help={t('Select the tags that can be used to group and filter the metric.')}
  237. creatable
  238. formatCreateLabel={value => `Custom: "${value}"`}
  239. onCreateOption={value => {
  240. setCustomeAttributes(curr => [...curr, value]);
  241. const currentTags = model.getValue('tags') as string[];
  242. model.setValue('tags', [...currentTags, value]);
  243. }}
  244. />
  245. <FormField
  246. label={t('Queries')}
  247. help={t(
  248. 'Define queries to narrow down the metric extraction to a specific set of spans.'
  249. )}
  250. name="conditions"
  251. inline={false}
  252. hasControlState={false}
  253. flexibleControlStateSize
  254. >
  255. {({onChange, initialData, value}) => {
  256. const conditions = (value ||
  257. initialData ||
  258. []) as MetricsExtractionCondition[];
  259. const handleChange = (queryString: string, index: number) => {
  260. onChange(
  261. conditions.toSpliced(index, 1, {
  262. ...conditions[index],
  263. value: queryString,
  264. }),
  265. {}
  266. );
  267. };
  268. return (
  269. <Fragment>
  270. <ConditionsWrapper hasDelete={value.length > 1}>
  271. {conditions.map((condition, index) => (
  272. <Fragment key={condition.id}>
  273. <SearchWrapper hasPrefix={conditions.length > 1}>
  274. {conditions.length > 1 && (
  275. <ConditionSymbol>{index + 1}</ConditionSymbol>
  276. )}
  277. <SearchBar
  278. {...SPAN_SEARCH_CONFIG}
  279. searchSource="metrics-extraction"
  280. query={condition.value}
  281. onSearch={(queryString: string) =>
  282. handleChange(queryString, index)
  283. }
  284. placeholder={t('Search for span attributes')}
  285. organization={organization}
  286. metricAlert={false}
  287. supportedTags={supportedTags}
  288. dataset={DiscoverDatasets.SPANS_INDEXED}
  289. projectIds={[parseInt(project.id, 10)]}
  290. hasRecentSearches={false}
  291. onBlur={(queryString: string) =>
  292. handleChange(queryString, index)
  293. }
  294. savedSearchType={undefined}
  295. useFormWrapper={false}
  296. />
  297. </SearchWrapper>
  298. {value.length > 1 && (
  299. <Button
  300. aria-label={t('Remove Query')}
  301. onClick={() => onChange(conditions.toSpliced(index, 1), {})}
  302. icon={<IconClose />}
  303. />
  304. )}
  305. </Fragment>
  306. ))}
  307. </ConditionsWrapper>
  308. <ConditionsButtonBar>
  309. <Button
  310. size="sm"
  311. onClick={() => onChange([...conditions, createCondition()], {})}
  312. icon={<IconAdd />}
  313. >
  314. {t('Add Query')}
  315. </Button>
  316. </ConditionsButtonBar>
  317. </Fragment>
  318. );
  319. }}
  320. </FormField>
  321. </Fragment>
  322. )}
  323. </Form>
  324. );
  325. }
  326. const ConditionsWrapper = styled('div')<{hasDelete: boolean}>`
  327. padding: ${space(1)} 0;
  328. display: grid;
  329. align-items: center;
  330. gap: ${space(1)};
  331. ${p =>
  332. p.hasDelete
  333. ? `
  334. grid-template-columns: 1fr min-content;
  335. `
  336. : `
  337. grid-template-columns: 1fr;
  338. `}
  339. `;
  340. const SearchWrapper = styled('div')<{hasPrefix: boolean}>`
  341. display: grid;
  342. gap: ${space(1)};
  343. align-items: center;
  344. ${p =>
  345. p.hasPrefix
  346. ? `
  347. grid-template-columns: max-content 1fr;
  348. `
  349. : `
  350. grid-template-columns: 1fr;
  351. `}
  352. `;
  353. const ConditionSymbol = styled('div')`
  354. background-color: ${p => p.theme.purple100};
  355. color: ${p => p.theme.purple400};
  356. text-align: center;
  357. align-content: center;
  358. height: ${space(3)};
  359. width: ${space(3)};
  360. border-radius: 50%;
  361. `;
  362. const ConditionsButtonBar = styled('div')`
  363. margin-top: ${space(1)};
  364. height: ${space(3)};
  365. `;