metricsExtractionRuleForm.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. import {Fragment, useCallback, useId, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Observer} from 'mobx-react';
  4. import Alert from 'sentry/components/alert';
  5. import {Button} from 'sentry/components/button';
  6. import SearchBar from 'sentry/components/events/searchBar';
  7. import SelectField from 'sentry/components/forms/fields/selectField';
  8. import Form, {type FormProps} from 'sentry/components/forms/form';
  9. import FormField from 'sentry/components/forms/formField';
  10. import type FormModel from 'sentry/components/forms/model';
  11. import ExternalLink from 'sentry/components/links/externalLink';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {IconAdd, IconClose, IconQuestion, IconWarning} from 'sentry/icons';
  14. import {t, tct} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {SelectValue} from 'sentry/types/core';
  17. import type {MetricAggregation, MetricsExtractionCondition} from 'sentry/types/metrics';
  18. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  19. import useOrganization from 'sentry/utils/useOrganization';
  20. import {SpanIndexedField} from 'sentry/views/insights/types';
  21. import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
  22. import {useMetricsExtractionRules} from 'sentry/views/settings/projectMetrics/utils/useMetricsExtractionRules';
  23. export type AggregateGroup = 'count' | 'count_unique' | 'min_max' | 'percentiles';
  24. export interface FormData {
  25. aggregates: AggregateGroup[];
  26. conditions: MetricsExtractionCondition[];
  27. spanAttribute: string | null;
  28. tags: string[];
  29. unit: string;
  30. }
  31. interface Props extends Omit<FormProps, 'onSubmit'> {
  32. initialData: FormData;
  33. projectId: string | number;
  34. cardinality?: Record<string, number>;
  35. isEdit?: boolean;
  36. onSubmit?: (
  37. data: FormData,
  38. onSubmitSuccess: (data: FormData) => void,
  39. onSubmitError: (error: any) => void,
  40. event: React.FormEvent,
  41. model: FormModel
  42. ) => void;
  43. }
  44. const HIGH_CARDINALITY_TAGS = new Set([
  45. SpanIndexedField.SPAN_DURATION,
  46. SpanIndexedField.SPAN_SELF_TIME,
  47. SpanIndexedField.PROJECT_ID,
  48. SpanIndexedField.INP,
  49. SpanIndexedField.INP_SCORE,
  50. SpanIndexedField.INP_SCORE_WEIGHT,
  51. SpanIndexedField.TOTAL_SCORE,
  52. SpanIndexedField.CACHE_ITEM_SIZE,
  53. SpanIndexedField.MESSAGING_MESSAGE_BODY_SIZE,
  54. SpanIndexedField.MESSAGING_MESSAGE_RECEIVE_LATENCY,
  55. SpanIndexedField.MESSAGING_MESSAGE_RETRY_COUNT,
  56. SpanIndexedField.TRANSACTION_ID,
  57. SpanIndexedField.ID,
  58. ]);
  59. const AGGREGATE_OPTIONS: {label: string; value: AggregateGroup}[] = [
  60. {
  61. label: t('count'),
  62. value: 'count',
  63. },
  64. {
  65. label: t('count_unique'),
  66. value: 'count_unique',
  67. },
  68. {
  69. label: t('min, max, sum, avg'),
  70. value: 'min_max',
  71. },
  72. {
  73. label: t('percentiles'),
  74. value: 'percentiles',
  75. },
  76. ];
  77. export function explodeAggregateGroup(group: AggregateGroup): MetricAggregation[] {
  78. switch (group) {
  79. case 'count':
  80. return ['count'];
  81. case 'count_unique':
  82. return ['count_unique'];
  83. case 'min_max':
  84. return ['min', 'max', 'sum', 'avg'];
  85. case 'percentiles':
  86. return ['p50', 'p75', 'p90', 'p95', 'p99'];
  87. default:
  88. throw new Error(`Unknown aggregate group: ${group}`);
  89. }
  90. }
  91. export function aggregatesToGroups(aggregates: MetricAggregation[]): AggregateGroup[] {
  92. const groups: AggregateGroup[] = [];
  93. if (aggregates.includes('count')) {
  94. groups.push('count');
  95. }
  96. if (aggregates.includes('count_unique')) {
  97. groups.push('count_unique');
  98. }
  99. const minMaxAggregates = new Set<MetricAggregation>(['min', 'max', 'sum', 'avg']);
  100. if (aggregates.find(aggregate => minMaxAggregates.has(aggregate))) {
  101. groups.push('min_max');
  102. }
  103. const percentileAggregates = new Set<MetricAggregation>([
  104. 'p50',
  105. 'p75',
  106. 'p90',
  107. 'p95',
  108. 'p99',
  109. ]);
  110. if (aggregates.find(aggregate => percentileAggregates.has(aggregate))) {
  111. groups.push('percentiles');
  112. }
  113. return groups;
  114. }
  115. let currentTempId = 0;
  116. function createTempId(): number {
  117. currentTempId -= 1;
  118. return currentTempId;
  119. }
  120. export function createCondition(): MetricsExtractionCondition {
  121. return {
  122. value: '',
  123. // id and mris will be set by the backend after creation
  124. id: createTempId(),
  125. mris: [],
  126. };
  127. }
  128. const SUPPORTED_UNITS = [
  129. 'none',
  130. 'nanosecond',
  131. 'microsecond',
  132. 'millisecond',
  133. 'second',
  134. 'minute',
  135. 'hour',
  136. 'day',
  137. 'week',
  138. 'ratio',
  139. 'percent',
  140. 'bit',
  141. 'byte',
  142. 'kibibyte',
  143. 'kilobyte',
  144. 'mebibyte',
  145. 'megabyte',
  146. 'gibibyte',
  147. 'gigabyte',
  148. 'tebibyte',
  149. 'terabyte',
  150. 'pebibyte',
  151. 'petabyte',
  152. 'exbibyte',
  153. 'exabyte',
  154. ] as const;
  155. const isSupportedUnit = (unit: string): unit is (typeof SUPPORTED_UNITS)[number] => {
  156. return SUPPORTED_UNITS.includes(unit as (typeof SUPPORTED_UNITS)[number]);
  157. };
  158. const EMPTY_SET = new Set<never>();
  159. const SPAN_SEARCH_CONFIG = {
  160. booleanKeys: EMPTY_SET,
  161. dateKeys: EMPTY_SET,
  162. durationKeys: EMPTY_SET,
  163. numericKeys: EMPTY_SET,
  164. percentageKeys: EMPTY_SET,
  165. sizeKeys: EMPTY_SET,
  166. textOperatorKeys: EMPTY_SET,
  167. disallowFreeText: true,
  168. disallowWildcard: true,
  169. disallowNegation: true,
  170. };
  171. const FIXED_UNITS_BY_ATTRIBUTE: Record<string, (typeof SUPPORTED_UNITS)[number]> = {
  172. [SpanIndexedField.SPAN_DURATION]: 'millisecond',
  173. };
  174. export function MetricsExtractionRuleForm({
  175. isEdit,
  176. projectId,
  177. onSubmit,
  178. cardinality,
  179. ...props
  180. }: Props) {
  181. const organization = useOrganization();
  182. const [customAttributes, setCustomAttributes] = useState<string[]>(() => {
  183. const {spanAttribute, tags} = props.initialData;
  184. return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)];
  185. });
  186. const [customUnit, setCustomUnit] = useState<string | null>(() => {
  187. const {unit} = props.initialData;
  188. return unit && !isSupportedUnit(unit) ? unit : null;
  189. });
  190. const [isUnitDisabled, setIsUnitDisabled] = useState(() => {
  191. const {spanAttribute} = props.initialData;
  192. return !!(spanAttribute && spanAttribute in FIXED_UNITS_BY_ATTRIBUTE);
  193. });
  194. const {data: extractionRules} = useMetricsExtractionRules({
  195. orgId: organization.slug,
  196. projectId: projectId,
  197. });
  198. const tags = useSpanFieldSupportedTags({projects: [Number(projectId)]});
  199. // TODO(aknaus): Make this nicer
  200. const supportedTags = useMemo(() => {
  201. const copy = {...tags};
  202. delete copy.has;
  203. return copy;
  204. }, [tags]);
  205. const allAttributeOptions = useMemo(() => {
  206. let keys = Object.keys(supportedTags);
  207. if (customAttributes.length) {
  208. keys = [...new Set(keys.concat(customAttributes))];
  209. }
  210. return keys.sort((a, b) => a.localeCompare(b));
  211. }, [customAttributes, supportedTags]);
  212. const attributeOptions = useMemo(() => {
  213. const disabledKeys = new Set(extractionRules?.map(rule => rule.spanAttribute) || []);
  214. return (
  215. allAttributeOptions
  216. .map<SelectValue<string>>(key => ({
  217. label: key,
  218. value: key,
  219. disabled: disabledKeys.has(key),
  220. tooltip: disabledKeys.has(key)
  221. ? t(
  222. 'This attribute is already in use. Please select another one or edit the existing metric.'
  223. )
  224. : undefined,
  225. tooltipOptions: {position: 'left'},
  226. }))
  227. // Sort disabled attributes to bottom
  228. .sort((a, b) => Number(a.disabled) - Number(b.disabled))
  229. );
  230. }, [allAttributeOptions, extractionRules]);
  231. const tagOptions = useMemo(() => {
  232. return allAttributeOptions
  233. .filter(
  234. // We don't want to suggest numeric fields as tags as they would explode cardinality
  235. option => !HIGH_CARDINALITY_TAGS.has(option as SpanIndexedField)
  236. )
  237. .map<SelectValue<string>>(option => ({
  238. label: option,
  239. value: option,
  240. }));
  241. }, [allAttributeOptions]);
  242. const unitOptions = useMemo(() => {
  243. const options: SelectValue<string>[] = SUPPORTED_UNITS.map(unit => ({
  244. label: unit + (unit === 'none' ? '' : 's'),
  245. value: unit,
  246. }));
  247. if (customUnit) {
  248. options.push({
  249. label: customUnit,
  250. value: customUnit,
  251. });
  252. }
  253. return options;
  254. }, [customUnit]);
  255. const handleSubmit = useCallback(
  256. (
  257. data: Record<string, any>,
  258. onSubmitSuccess: (data: Record<string, any>) => void,
  259. onSubmitError: (error: any) => void,
  260. event: React.FormEvent,
  261. model: FormModel
  262. ) => {
  263. const errors: Record<string, [string]> = {};
  264. if (!data.spanAttribute) {
  265. errors.spanAttribute = [t('Span attribute is required.')];
  266. }
  267. if (!data.aggregates.length) {
  268. errors.aggregates = [t('At least one aggregate is required.')];
  269. }
  270. if (Object.keys(errors).length) {
  271. onSubmitError({responseJSON: errors});
  272. return;
  273. }
  274. onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model);
  275. },
  276. [onSubmit]
  277. );
  278. return (
  279. <Form onSubmit={onSubmit && handleSubmit} {...props}>
  280. {({model}) => (
  281. <Fragment>
  282. <SpanAttributeUnitWrapper>
  283. <SelectField
  284. inline={false}
  285. stacked
  286. name="spanAttribute"
  287. options={attributeOptions}
  288. disabled={isEdit}
  289. label={
  290. <TooltipIconLabel
  291. label={t('Measure')}
  292. help={tct(
  293. 'Define the span attribute you want to track. Learn how to instrument custom attributes in [link:our docs].',
  294. {
  295. // TODO(telemetry-experience): add the correct link here once we have it!!!
  296. link: (
  297. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  298. ),
  299. }
  300. )}
  301. />
  302. }
  303. placeholder={t('Select span attribute')}
  304. creatable
  305. formatCreateLabel={value => `Custom: "${value}"`}
  306. onCreateOption={value => {
  307. setCustomAttributes(curr => [...curr, value]);
  308. model.setValue('spanAttribute', value);
  309. }}
  310. onChange={value => {
  311. model.setValue('spanAttribute', value);
  312. if (value in FIXED_UNITS_BY_ATTRIBUTE) {
  313. model.setValue('unit', FIXED_UNITS_BY_ATTRIBUTE[value]);
  314. setIsUnitDisabled(true);
  315. } else {
  316. setIsUnitDisabled(false);
  317. }
  318. }}
  319. required
  320. />
  321. <StyledFieldConnector>in</StyledFieldConnector>
  322. <SelectField
  323. inline={false}
  324. stacked
  325. allowEmpty
  326. name="unit"
  327. options={unitOptions}
  328. disabled={isUnitDisabled}
  329. label={
  330. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/">
  331. {t('Create Custom Attribute?')}
  332. </ExternalLink>
  333. }
  334. placeholder={t('Select unit')}
  335. creatable
  336. formatCreateLabel={value => `Custom: "${value}"`}
  337. onCreateOption={value => {
  338. setCustomUnit(value);
  339. model.setValue('unit', value);
  340. }}
  341. />
  342. </SpanAttributeUnitWrapper>
  343. <SelectField
  344. inline={false}
  345. stacked
  346. name="aggregates"
  347. required
  348. options={AGGREGATE_OPTIONS}
  349. label={
  350. <TooltipIconLabel
  351. label={t('Aggregate')}
  352. help={tct(
  353. 'Select the aggregations you want to store. For more information, read [link:our docs]',
  354. {
  355. // TODO(telemetry-experience): add the correct link here once we have it!!!
  356. link: (
  357. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  358. ),
  359. }
  360. )}
  361. />
  362. }
  363. multiple
  364. />
  365. <SelectField
  366. inline={false}
  367. stacked
  368. name="tags"
  369. options={tagOptions}
  370. multiple
  371. placeholder={t('Select tags')}
  372. label={
  373. <TooltipIconLabel
  374. label={t('Group and filter by')}
  375. help={t(
  376. 'Select the tags that can be used to group and filter the metric. Tag values have to be non-numeric.'
  377. )}
  378. />
  379. }
  380. creatable
  381. formatCreateLabel={value => `Custom: "${value}"`}
  382. onCreateOption={value => {
  383. setCustomAttributes(curr => [...curr, value]);
  384. const currentTags = model.getValue('tags') as string[];
  385. model.setValue('tags', [...currentTags, value]);
  386. }}
  387. />
  388. <FormField
  389. stacked
  390. label={
  391. <TooltipIconLabel
  392. label={t('Filters')}
  393. help={t(
  394. 'Define filters to narrow down the metric to a specific set of spans.'
  395. )}
  396. />
  397. }
  398. name="conditions"
  399. inline={false}
  400. hasControlState={false}
  401. flexibleControlStateSize
  402. >
  403. {({onChange, initialData, value}) => {
  404. const conditions = (value ||
  405. initialData ||
  406. []) as MetricsExtractionCondition[];
  407. const handleChange = (queryString: string, index: number) => {
  408. onChange(
  409. conditions.toSpliced(index, 1, {
  410. ...conditions[index],
  411. value: queryString,
  412. }),
  413. {}
  414. );
  415. };
  416. const isCardinalityLimited = (
  417. condition: MetricsExtractionCondition
  418. ): boolean => {
  419. if (!cardinality) {
  420. return false;
  421. }
  422. return condition.mris.some(conditionMri => cardinality[conditionMri] > 0);
  423. };
  424. return (
  425. <Fragment>
  426. <ConditionsWrapper hasDelete={value.length > 1}>
  427. {conditions.map((condition, index) => {
  428. const isExeedingCardinalityLimit = isCardinalityLimited(condition);
  429. const hasSiblings = conditions.length > 1;
  430. return (
  431. <Fragment key={condition.id}>
  432. <SearchWrapper
  433. hasPrefix={hasSiblings || isExeedingCardinalityLimit}
  434. >
  435. {hasSiblings || isExeedingCardinalityLimit ? (
  436. isExeedingCardinalityLimit ? (
  437. <Tooltip
  438. title={t(
  439. 'This filter is exeeding the cardinality limit. Remove tags or add more conditions to receive accurate data.'
  440. )}
  441. >
  442. <StyledIconWarning size="xs" color="yellow300" />
  443. </Tooltip>
  444. ) : (
  445. <ConditionSymbol>{index + 1}</ConditionSymbol>
  446. )
  447. ) : null}
  448. <SearchBarWithId
  449. {...SPAN_SEARCH_CONFIG}
  450. searchSource="metrics-extraction"
  451. query={condition.value}
  452. onSearch={(queryString: string) =>
  453. handleChange(queryString, index)
  454. }
  455. onClose={(queryString: string, {validSearch}) => {
  456. if (validSearch) {
  457. handleChange(queryString, index);
  458. }
  459. }}
  460. placeholder={t('Add span attributes')}
  461. organization={organization}
  462. supportedTags={supportedTags}
  463. dataset={DiscoverDatasets.SPANS_INDEXED}
  464. projectIds={[Number(projectId)]}
  465. hasRecentSearches={false}
  466. savedSearchType={undefined}
  467. useFormWrapper={false}
  468. />
  469. </SearchWrapper>
  470. {value.length > 1 && (
  471. <Button
  472. aria-label={t('Remove Filter')}
  473. onClick={() => onChange(conditions.toSpliced(index, 1), {})}
  474. icon={<IconClose />}
  475. />
  476. )}
  477. </Fragment>
  478. );
  479. })}
  480. </ConditionsWrapper>
  481. <ConditionsButtonBar>
  482. <Button
  483. size="sm"
  484. onClick={() => onChange([...conditions, createCondition()], {})}
  485. icon={<IconAdd />}
  486. >
  487. {t('Add Filter')}
  488. </Button>
  489. </ConditionsButtonBar>
  490. </Fragment>
  491. );
  492. }}
  493. </FormField>
  494. <Observer>
  495. {() =>
  496. model.formChanged ? (
  497. <Alert
  498. type="info"
  499. showIcon
  500. expand={
  501. <Fragment>
  502. <b>{t('Why that?')}</b>
  503. <p>
  504. {t(
  505. 'Well, it’s because we’ll only collect data once you’ve created a metric and not before. Likewise, if you deleted an existing metric, then we’ll stop collecting data for that metric.'
  506. )}
  507. </p>
  508. </Fragment>
  509. }
  510. >
  511. {t('Hey, we’ll need a moment to collect data that matches the above.')}
  512. </Alert>
  513. ) : null
  514. }
  515. </Observer>
  516. </Fragment>
  517. )}
  518. </Form>
  519. );
  520. }
  521. function TooltipIconLabel({label, help}) {
  522. return (
  523. <TooltipIconLabelWrapper>
  524. {label}
  525. <Tooltip title={help}>
  526. <IconQuestion size="sm" color="gray200" />
  527. </Tooltip>
  528. </TooltipIconLabelWrapper>
  529. );
  530. }
  531. const TooltipIconLabelWrapper = styled('span')`
  532. display: inline-flex;
  533. font-weight: bold;
  534. color: ${p => p.theme.gray300};
  535. gap: ${space(0.5)};
  536. & > span {
  537. margin-top: 1px;
  538. }
  539. & > span:hover {
  540. cursor: pointer;
  541. }
  542. `;
  543. const StyledFieldConnector = styled('div')`
  544. color: ${p => p.theme.gray300};
  545. padding-bottom: ${space(1)};
  546. `;
  547. const SpanAttributeUnitWrapper = styled('div')`
  548. display: flex;
  549. align-items: flex-end;
  550. gap: ${space(1)};
  551. padding-bottom: ${space(2)};
  552. & > div:first-child {
  553. flex: 1;
  554. padding-bottom: 0;
  555. }
  556. `;
  557. function SearchBarWithId(props: React.ComponentProps<typeof SearchBar>) {
  558. const id = useId();
  559. return <SearchBar id={id} {...props} />;
  560. }
  561. const ConditionsWrapper = styled('div')<{hasDelete: boolean}>`
  562. display: grid;
  563. align-items: center;
  564. gap: ${space(1)};
  565. ${p =>
  566. p.hasDelete
  567. ? `
  568. grid-template-columns: 1fr min-content;
  569. `
  570. : `
  571. grid-template-columns: 1fr;
  572. `}
  573. `;
  574. const SearchWrapper = styled('div')<{hasPrefix?: boolean}>`
  575. display: grid;
  576. gap: ${space(1)};
  577. align-items: center;
  578. grid-template-columns: ${p => (p.hasPrefix ? 'max-content' : '')} 1fr;
  579. `;
  580. const ConditionSymbol = styled('div')`
  581. background-color: ${p => p.theme.purple100};
  582. color: ${p => p.theme.purple400};
  583. text-align: center;
  584. align-content: center;
  585. height: ${space(3)};
  586. width: ${space(3)};
  587. border-radius: 50%;
  588. `;
  589. const StyledIconWarning = styled(IconWarning)`
  590. margin: 0 ${space(0.5)};
  591. `;
  592. const ConditionsButtonBar = styled('div')`
  593. margin-top: ${space(1)};
  594. `;