metricsExtractionRuleForm.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. import {
  2. Fragment,
  3. memo,
  4. useCallback,
  5. useLayoutEffect,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. import styled from '@emotion/styled';
  11. import {Button} from 'sentry/components/button';
  12. import SearchBar from 'sentry/components/events/searchBar';
  13. import SelectField from 'sentry/components/forms/fields/selectField';
  14. import Form, {type FormProps} from 'sentry/components/forms/form';
  15. import FormField from 'sentry/components/forms/formField';
  16. import type FormModel from 'sentry/components/forms/model';
  17. import ExternalLink from 'sentry/components/links/externalLink';
  18. import {IconAdd, IconClose} from 'sentry/icons';
  19. import {t, tct} from 'sentry/locale';
  20. import {space} from 'sentry/styles/space';
  21. import type {TagCollection} from 'sentry/types/group';
  22. import type {MetricsAggregate, MetricsExtractionCondition} from 'sentry/types/metrics';
  23. import type {Project} from 'sentry/types/project';
  24. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  25. import useOrganization from 'sentry/utils/useOrganization';
  26. import {SpanIndexedField} from 'sentry/views/insights/types';
  27. import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
  28. export type AggregateGroup = 'count' | 'count_unique' | 'min_max' | 'percentiles';
  29. export interface FormData {
  30. aggregates: AggregateGroup[];
  31. conditions: MetricsExtractionCondition[];
  32. spanAttribute: string | null;
  33. tags: string[];
  34. }
  35. interface Props extends Omit<FormProps, 'onSubmit'> {
  36. initialData: FormData;
  37. project: Project;
  38. isEdit?: boolean;
  39. onSubmit?: (
  40. data: FormData,
  41. onSubmitSuccess: (data: FormData) => void,
  42. onSubmitError: (error: any) => void,
  43. event: React.FormEvent,
  44. model: FormModel
  45. ) => void;
  46. }
  47. const KNOWN_NUMERIC_FIELDS = new Set([
  48. SpanIndexedField.SPAN_DURATION,
  49. SpanIndexedField.SPAN_SELF_TIME,
  50. SpanIndexedField.PROJECT_ID,
  51. SpanIndexedField.INP,
  52. SpanIndexedField.INP_SCORE,
  53. SpanIndexedField.INP_SCORE_WEIGHT,
  54. SpanIndexedField.TOTAL_SCORE,
  55. SpanIndexedField.CACHE_ITEM_SIZE,
  56. SpanIndexedField.MESSAGING_MESSAGE_BODY_SIZE,
  57. SpanIndexedField.MESSAGING_MESSAGE_RECEIVE_LATENCY,
  58. SpanIndexedField.MESSAGING_MESSAGE_RETRY_COUNT,
  59. ]);
  60. const AGGREGATE_OPTIONS: {label: string; value: AggregateGroup}[] = [
  61. {
  62. label: t('count'),
  63. value: 'count',
  64. },
  65. {
  66. label: t('count_unique'),
  67. value: 'count_unique',
  68. },
  69. {
  70. label: t('min, max, sum, avg'),
  71. value: 'min_max',
  72. },
  73. {
  74. label: t('percentiles'),
  75. value: 'percentiles',
  76. },
  77. ];
  78. export function explodeAggregateGroup(group: AggregateGroup): MetricsAggregate[] {
  79. switch (group) {
  80. case 'count':
  81. return ['count'];
  82. case 'count_unique':
  83. return ['count_unique'];
  84. case 'min_max':
  85. return ['min', 'max', 'sum', 'avg'];
  86. case 'percentiles':
  87. return ['p50', 'p75', 'p95', 'p99'];
  88. default:
  89. throw new Error(`Unknown aggregate group: ${group}`);
  90. }
  91. }
  92. export function aggregatesToGroups(aggregates: MetricsAggregate[]): AggregateGroup[] {
  93. const groups: AggregateGroup[] = [];
  94. if (aggregates.includes('count')) {
  95. groups.push('count');
  96. }
  97. if (aggregates.includes('count_unique')) {
  98. groups.push('count_unique');
  99. }
  100. const minMaxAggregates = new Set<MetricsAggregate>(['min', 'max', 'sum', 'avg']);
  101. if (aggregates.find(aggregate => minMaxAggregates.has(aggregate))) {
  102. groups.push('min_max');
  103. }
  104. const percentileAggregates = new Set<MetricsAggregate>(['p50', 'p75', 'p95', 'p99']);
  105. if (aggregates.find(aggregate => percentileAggregates.has(aggregate))) {
  106. groups.push('percentiles');
  107. }
  108. return groups;
  109. }
  110. let currentTempId = 0;
  111. function createTempId(): number {
  112. currentTempId -= 1;
  113. return currentTempId;
  114. }
  115. export function createCondition(): MetricsExtractionCondition {
  116. return {
  117. query: '',
  118. // id and mris will be set by the backend after creation
  119. id: createTempId(),
  120. mris: [],
  121. };
  122. }
  123. const EMPTY_SET = new Set<never>();
  124. const SPAN_SEARCH_CONFIG = {
  125. booleanKeys: EMPTY_SET,
  126. dateKeys: EMPTY_SET,
  127. durationKeys: EMPTY_SET,
  128. numericKeys: EMPTY_SET,
  129. percentageKeys: EMPTY_SET,
  130. sizeKeys: EMPTY_SET,
  131. textOperatorKeys: EMPTY_SET,
  132. disallowFreeText: true,
  133. disallowWildcard: true,
  134. disallowNegation: true,
  135. };
  136. export function MetricsExtractionRuleForm({isEdit, project, onSubmit, ...props}: Props) {
  137. const [customAttributes, setCustomeAttributes] = useState<string[]>(() => {
  138. const {spanAttribute, tags} = props.initialData;
  139. return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)];
  140. });
  141. const tags = useSpanFieldSupportedTags({projects: [parseInt(project.id, 10)]});
  142. // TODO(aknaus): Make this nicer
  143. const supportedTags = useMemo(() => {
  144. const copy = {...tags};
  145. delete copy.has;
  146. return copy;
  147. }, [tags]);
  148. const attributeOptions = useMemo(() => {
  149. let keys = Object.keys(supportedTags);
  150. if (customAttributes.length) {
  151. keys = [...new Set(keys.concat(customAttributes))];
  152. }
  153. return keys
  154. .map(key => ({
  155. label: key,
  156. value: key,
  157. }))
  158. .sort((a, b) => a.label.localeCompare(b.label));
  159. }, [customAttributes, supportedTags]);
  160. const tagOptions = useMemo(() => {
  161. return attributeOptions.filter(
  162. // We don't want to suggest numeric fields as tags as they would explode cardinality
  163. option => !KNOWN_NUMERIC_FIELDS.has(option.value as SpanIndexedField)
  164. );
  165. }, [attributeOptions]);
  166. const handleSubmit = useCallback(
  167. (
  168. data: Record<string, any>,
  169. onSubmitSuccess: (data: Record<string, any>) => void,
  170. onSubmitError: (error: any) => void,
  171. event: React.FormEvent,
  172. model: FormModel
  173. ) => {
  174. onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model);
  175. },
  176. [onSubmit]
  177. );
  178. const projectIds = useMemo(() => [parseInt(project.id, 10)], [project.id]);
  179. return (
  180. <Form onSubmit={onSubmit && handleSubmit} {...props}>
  181. {({model}) => (
  182. <Fragment>
  183. <SelectField
  184. name="spanAttribute"
  185. options={attributeOptions}
  186. disabled={isEdit}
  187. label={t('Measure')}
  188. help={tct(
  189. 'Define the span attribute you want to track. Learn how to instrument custom attributes in [link:our docs].',
  190. {
  191. // TODO(telemetry-experience): add the correct link here once we have it!!!
  192. link: (
  193. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  194. ),
  195. }
  196. )}
  197. placeholder={t('Select a span attribute')}
  198. creatable
  199. formatCreateLabel={value => `Custom: "${value}"`}
  200. onCreateOption={value => {
  201. setCustomeAttributes(curr => [...curr, value]);
  202. model.setValue('spanAttribute', value);
  203. }}
  204. required
  205. />
  206. <SelectField
  207. name="aggregates"
  208. required
  209. options={AGGREGATE_OPTIONS}
  210. label={t('Aggregate')}
  211. multiple
  212. help={tct(
  213. 'Select the aggregations you want to store. For more information, read [link:our docs]',
  214. {
  215. // TODO(telemetry-experience): add the correct link here once we have it!!!
  216. link: (
  217. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  218. ),
  219. }
  220. )}
  221. />
  222. <SelectField
  223. name="tags"
  224. options={tagOptions}
  225. label={t('Group and filter by')}
  226. multiple
  227. placeholder={t('Select tags')}
  228. help={t('Select the tags that can be used to group and filter the metric.')}
  229. creatable
  230. formatCreateLabel={value => `Custom: "${value}"`}
  231. onCreateOption={value => {
  232. setCustomeAttributes(curr => [...curr, value]);
  233. const currentTags = model.getValue('tags') as string[];
  234. model.setValue('tags', [...currentTags, value]);
  235. }}
  236. />
  237. <FormField
  238. label={t('Queries')}
  239. help={t(
  240. 'Define queries to narrow down the metric extraction to a specific set of spans.'
  241. )}
  242. name="conditions"
  243. inline={false}
  244. hasControlState={false}
  245. flexibleControlStateSize
  246. >
  247. {({onChange, initialData, value}) => {
  248. const conditions = (value ||
  249. initialData ||
  250. []) as MetricsExtractionCondition[];
  251. return (
  252. <Conditions
  253. conditions={conditions}
  254. projectIds={projectIds}
  255. supportedTags={supportedTags}
  256. onChange={onChange}
  257. />
  258. );
  259. }}
  260. </FormField>
  261. </Fragment>
  262. )}
  263. </Form>
  264. );
  265. }
  266. interface ConditionsProps {
  267. conditions: MetricsExtractionCondition[];
  268. onChange: (conditions: MetricsExtractionCondition[], meta: Record<string, any>) => void;
  269. projectIds: number[];
  270. supportedTags: TagCollection;
  271. }
  272. function useStableCallback<T extends (...args: any[]) => any>(
  273. handler: T
  274. ): (...args: Parameters<T>) => ReturnType<T> {
  275. const handlerRef = useRef(handler);
  276. // Just setting the ref during render is not concurrent mode safe
  277. // as non-commited low-prio render could set it to the wrong value
  278. useLayoutEffect(() => {
  279. handlerRef.current = handler;
  280. }, [handler]);
  281. return useCallback((...args: Parameters<T>) => handlerRef.current(...args), []);
  282. }
  283. function Conditions({conditions, projectIds, supportedTags, onChange}: ConditionsProps) {
  284. // Using useStableCallback to prevent callback references changing on value changes
  285. const handleChange = useStableCallback((index: number, queryString: string) => {
  286. onChange(
  287. conditions.toSpliced(index, 1, {
  288. ...conditions[index],
  289. query: queryString,
  290. }),
  291. {}
  292. );
  293. });
  294. const handleDelete = useStableCallback((index: number) => {
  295. onChange(conditions.toSpliced(index, 1), {});
  296. });
  297. const handleCreate = useStableCallback(() => {
  298. onChange([...conditions, createCondition()], {});
  299. });
  300. return (
  301. <Fragment>
  302. <ConditionsWrapper hasDelete={conditions.length > 1}>
  303. {conditions.map((condition, index) => (
  304. <ConditionItem
  305. key={condition.id}
  306. condition={condition}
  307. index={index}
  308. onDelete={conditions.length > 1 ? handleDelete : undefined}
  309. onChange={handleChange}
  310. supportedTags={supportedTags}
  311. projectIds={projectIds}
  312. />
  313. ))}
  314. </ConditionsWrapper>
  315. <ConditionsButtonBar>
  316. <Button onClick={handleCreate} icon={<IconAdd />}>
  317. {t('Add Query')}
  318. </Button>
  319. </ConditionsButtonBar>
  320. </Fragment>
  321. );
  322. }
  323. interface ConditionItemProps {
  324. condition: MetricsExtractionCondition;
  325. index: number;
  326. onChange: (index: number, query: string) => void;
  327. projectIds: number[];
  328. supportedTags: TagCollection;
  329. onDelete?: (index: number) => void;
  330. }
  331. const ConditionItem = memo(function ConditionItem({
  332. condition,
  333. index,
  334. onDelete,
  335. onChange,
  336. supportedTags,
  337. projectIds,
  338. }: ConditionItemProps) {
  339. const organization = useOrganization();
  340. const handleChange = useCallback(
  341. (queryString: string) => {
  342. onChange(index, queryString);
  343. },
  344. [index, onChange]
  345. );
  346. const handleDelete = useCallback(() => {
  347. onDelete?.(index);
  348. }, [index, onDelete]);
  349. return (
  350. <Fragment key={condition.id}>
  351. <SearchWrapper hasPrefix={index !== 0}>
  352. {index !== 0 && <ConditionLetter>{t('or')}</ConditionLetter>}
  353. <SearchBar
  354. {...SPAN_SEARCH_CONFIG}
  355. searchSource="metrics-extraction"
  356. query={condition.query}
  357. onSearch={handleChange}
  358. placeholder={t('Search for span attributes')}
  359. organization={organization}
  360. metricAlert={false}
  361. supportedTags={supportedTags}
  362. dataset={DiscoverDatasets.SPANS_INDEXED}
  363. projectIds={projectIds}
  364. hasRecentSearches={false}
  365. onBlur={handleChange}
  366. savedSearchType={undefined}
  367. useFormWrapper={false}
  368. />
  369. </SearchWrapper>
  370. {onDelete ? (
  371. <Button
  372. aria-label={t('Remove Query')}
  373. onClick={handleDelete}
  374. icon={<IconClose />}
  375. />
  376. ) : null}
  377. </Fragment>
  378. );
  379. });
  380. const ConditionsWrapper = styled('div')<{hasDelete: boolean}>`
  381. display: grid;
  382. gap: ${space(1)};
  383. ${p =>
  384. p.hasDelete
  385. ? `
  386. grid-template-columns: 1fr min-content;
  387. `
  388. : `
  389. grid-template-columns: 1fr;
  390. `}
  391. `;
  392. const SearchWrapper = styled('div')<{hasPrefix: boolean}>`
  393. display: grid;
  394. gap: ${space(1)};
  395. ${p =>
  396. p.hasPrefix
  397. ? `
  398. grid-template-columns: max-content 1fr;
  399. `
  400. : `
  401. grid-template-columns: 1fr;
  402. `}
  403. `;
  404. const ConditionLetter = styled('div')`
  405. background-color: ${p => p.theme.purple100};
  406. border-radius: ${p => p.theme.borderRadius};
  407. text-align: center;
  408. padding: 0 ${space(2)};
  409. color: ${p => p.theme.purple400};
  410. white-space: nowrap;
  411. font-weight: ${p => p.theme.fontWeightBold};
  412. align-content: center;
  413. `;
  414. const ConditionsButtonBar = styled('div')`
  415. margin-top: ${space(1)};
  416. `;