index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import {Fragment, KeyboardEvent, useEffect, useState} from 'react';
  2. import {createFilter} from 'react-select';
  3. import styled from '@emotion/styled';
  4. import partition from 'lodash/partition';
  5. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  7. import Button from 'sentry/components/button';
  8. import ButtonBar from 'sentry/components/buttonBar';
  9. import CompactSelect from 'sentry/components/forms/compactSelect';
  10. import FieldRequiredBadge from 'sentry/components/forms/field/fieldRequiredBadge';
  11. import NumberField from 'sentry/components/forms/numberField';
  12. import Option from 'sentry/components/forms/selectOption';
  13. import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
  14. import {IconAdd} from 'sentry/icons';
  15. import {IconSearch} from 'sentry/icons/iconSearch';
  16. import {t} from 'sentry/locale';
  17. import ProjectStore from 'sentry/stores/projectsStore';
  18. import space from 'sentry/styles/space';
  19. import {Organization, Project, SelectValue} from 'sentry/types';
  20. import {
  21. SamplingConditionOperator,
  22. SamplingInnerName,
  23. SamplingRule,
  24. SamplingRuleType,
  25. } from 'sentry/types/sampling';
  26. import {defined} from 'sentry/utils';
  27. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  28. import useApi from 'sentry/utils/useApi';
  29. import EmptyMessage from 'sentry/views/settings/components/emptyMessage';
  30. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  31. import {isUniformRule, percentageToRate, rateToPercentage} from '../../utils';
  32. import {Condition, Conditions} from './conditions';
  33. import {
  34. distributedTracesConditions,
  35. generateConditionCategoriesOptions,
  36. getErrorMessage,
  37. getNewCondition,
  38. } from './utils';
  39. const conditionAlreadyAddedTooltip = t('This condition has already been added');
  40. type State = {
  41. conditions: Condition[];
  42. errors: {
  43. sampleRate?: string;
  44. };
  45. samplePercentage: number | null;
  46. };
  47. type Props = ModalRenderProps & {
  48. organization: Organization;
  49. project: Project;
  50. rules: SamplingRule[];
  51. rule?: SamplingRule;
  52. };
  53. export function SpecificConditionsModal({
  54. Header,
  55. Body,
  56. Footer,
  57. closeModal,
  58. project,
  59. rule,
  60. rules,
  61. organization,
  62. }: Props) {
  63. const api = useApi();
  64. const [data, setData] = useState<State>(getInitialState());
  65. const [isSaving, setIsSaving] = useState(false);
  66. const conditionCategories = generateConditionCategoriesOptions(
  67. distributedTracesConditions
  68. );
  69. useEffect(() => {
  70. setData(d => {
  71. if (!!d.errors.sampleRate) {
  72. return {...d, errors: {...d.errors, sampleRate: undefined}};
  73. }
  74. return d;
  75. });
  76. }, [data.samplePercentage]);
  77. function getInitialState(): State {
  78. if (rule) {
  79. const {condition: conditions, sampleRate} = rule;
  80. const {inner} = conditions;
  81. return {
  82. conditions: inner.map(innerItem => {
  83. const {name, value} = innerItem;
  84. if (Array.isArray(value)) {
  85. return {
  86. category: name,
  87. match: value.join('\n'),
  88. };
  89. }
  90. return {category: name};
  91. }),
  92. samplePercentage: rateToPercentage(sampleRate) ?? null,
  93. errors: {},
  94. };
  95. }
  96. return {
  97. conditions: [],
  98. samplePercentage: null,
  99. errors: {},
  100. };
  101. }
  102. const {errors, conditions, samplePercentage} = data;
  103. function convertRequestErrorResponse(error: ReturnType<typeof getErrorMessage>) {
  104. if (typeof error === 'string') {
  105. addErrorMessage(error);
  106. return;
  107. }
  108. switch (error.type) {
  109. case 'sampleRate':
  110. setData({...data, errors: {...errors, sampleRate: error.message}});
  111. break;
  112. default:
  113. addErrorMessage(error.message);
  114. }
  115. }
  116. async function handleSubmit() {
  117. if (!defined(samplePercentage)) {
  118. return;
  119. }
  120. const sampleRate = percentageToRate(samplePercentage)!;
  121. const newRule: SamplingRule = {
  122. // All new/updated rules must have id equal to 0
  123. id: 0,
  124. active: rule ? rule.active : false,
  125. type: SamplingRuleType.TRACE,
  126. condition: {
  127. op: SamplingConditionOperator.AND,
  128. inner: !conditions.length ? [] : conditions.map(getNewCondition),
  129. },
  130. sampleRate,
  131. };
  132. const newRules = rule
  133. ? rules.map(existingRule => (existingRule.id === rule.id ? newRule : existingRule))
  134. : [...rules, newRule];
  135. // Make sure that a uniform rule is always send in the last position of the rules array
  136. const [uniformRule, specificRules] = partition(newRules, isUniformRule);
  137. setIsSaving(true);
  138. try {
  139. const response = await api.requestPromise(
  140. `/projects/${organization.slug}/${project.slug}/`,
  141. {
  142. method: 'PUT',
  143. data: {dynamicSampling: {rules: [...specificRules, ...uniformRule]}},
  144. }
  145. );
  146. ProjectStore.onUpdateSuccess(response);
  147. addSuccessMessage(
  148. rule
  149. ? t('Successfully edited sampling rule')
  150. : t('Successfully added sampling rule')
  151. );
  152. closeModal();
  153. } catch (error) {
  154. const currentRuleIndex = newRules.findIndex(newR => newR === newRule);
  155. convertRequestErrorResponse(getErrorMessage(error, currentRuleIndex));
  156. }
  157. setIsSaving(false);
  158. const analyticsConditions = conditions.map(condition => condition.category);
  159. const analyticsConditionsStringified = analyticsConditions.sort().join(', ');
  160. trackAdvancedAnalyticsEvent('sampling.settings.rule.specific_save', {
  161. organization,
  162. project_id: project.id,
  163. sampling_rate: sampleRate,
  164. conditions: analyticsConditions,
  165. conditions_stringified: analyticsConditionsStringified,
  166. });
  167. if (defined(rule)) {
  168. trackAdvancedAnalyticsEvent('sampling.settings.rule.specific_update', {
  169. organization,
  170. project_id: project.id,
  171. sampling_rate: sampleRate,
  172. conditions: analyticsConditions,
  173. conditions_stringified: analyticsConditionsStringified,
  174. old_conditions: rule.condition.inner.map(({name}) => name),
  175. old_conditions_stringified: rule.condition.inner
  176. .map(({name}) => name)
  177. .sort()
  178. .join(', '),
  179. old_sampling_rate: rule.sampleRate,
  180. });
  181. return;
  182. }
  183. trackAdvancedAnalyticsEvent('sampling.settings.rule.specific_create', {
  184. organization,
  185. project_id: project.id,
  186. sampling_rate: sampleRate,
  187. conditions: analyticsConditions,
  188. conditions_stringified: analyticsConditionsStringified,
  189. });
  190. }
  191. function handleAddCondition(selectedOptions: SelectValue<SamplingInnerName>[]) {
  192. const previousCategories = conditions.map(({category}) => category);
  193. const addedCategories = selectedOptions
  194. .filter(({value}) => !previousCategories.includes(value))
  195. .map(({value}) => value);
  196. trackAdvancedAnalyticsEvent('sampling.settings.modal.specific.rule.condition_add', {
  197. organization,
  198. project_id: project.id,
  199. conditions: addedCategories,
  200. });
  201. setData({
  202. ...data,
  203. conditions: [
  204. ...conditions,
  205. ...addedCategories.map(addedCategory => ({category: addedCategory, match: ''})),
  206. ],
  207. });
  208. }
  209. function handleDeleteCondition(index: number) {
  210. const newConditions = [...conditions];
  211. newConditions.splice(index, 1);
  212. setData({...data, conditions: newConditions});
  213. }
  214. function handleChangeCondition<T extends keyof Condition>(
  215. index: number,
  216. field: T,
  217. value: Condition[T]
  218. ) {
  219. const newConditions = [...conditions];
  220. newConditions[index][field] = value;
  221. // If custom tag key changes, reset the value
  222. if (field === 'category') {
  223. newConditions[index].match = '';
  224. trackAdvancedAnalyticsEvent('sampling.settings.modal.specific.rule.condition_add', {
  225. organization,
  226. project_id: project.id,
  227. conditions: [value as SamplingInnerName],
  228. });
  229. }
  230. setData({...data, conditions: newConditions});
  231. }
  232. const predefinedConditionsOptions = conditionCategories.map(([value, label]) => {
  233. const optionDisabled = conditions.some(condition => condition.category === value);
  234. return {
  235. value,
  236. label,
  237. disabled: optionDisabled,
  238. tooltip: optionDisabled ? conditionAlreadyAddedTooltip : undefined,
  239. };
  240. });
  241. const submitDisabled =
  242. !defined(samplePercentage) ||
  243. !conditions.length ||
  244. conditions.some(condition => !condition.match);
  245. return (
  246. <Fragment>
  247. <Header closeButton>
  248. <h4>{rule ? t('Edit Rule') : t('Add Rule')}</h4>
  249. </Header>
  250. <Body>
  251. <Fields>
  252. <Description>
  253. {t(
  254. 'Sample transactions under specific conditions. Multiple conditions are logically expressed as AND and OR for multiple values.'
  255. )}
  256. </Description>
  257. <StyledPanel>
  258. <StyledPanelHeader hasButtons>
  259. <div>
  260. {t('Conditions')}
  261. <FieldRequiredBadge />
  262. </div>
  263. <StyledCompactSelect
  264. placement="bottom right"
  265. triggerProps={{
  266. size: 'sm',
  267. 'aria-label': t('Add Condition'),
  268. }}
  269. triggerLabel={
  270. <TriggerLabel>
  271. <IconAdd isCircled />
  272. {t('Add Condition')}
  273. </TriggerLabel>
  274. }
  275. placeholder={t('Filter conditions')}
  276. isOptionDisabled={opt => opt.disabled}
  277. isDisabled={isUniformRule(rule)}
  278. options={predefinedConditionsOptions}
  279. value={conditions.map(({category}) => category)}
  280. onChange={handleAddCondition}
  281. isSearchable
  282. multiple
  283. filterOption={(candidate, input) => createFilter(null)(candidate, input)}
  284. components={{
  285. Option: containerProps => <Option {...containerProps} />,
  286. }}
  287. />
  288. </StyledPanelHeader>
  289. <PanelBody>
  290. {!conditions.length ? (
  291. <EmptyMessage
  292. icon={<IconSearch size="xl" />}
  293. title={t('No conditions added')}
  294. description={t('Click on the button above to add (+) a condition')}
  295. />
  296. ) : (
  297. <Conditions
  298. conditions={conditions}
  299. onDelete={handleDeleteCondition}
  300. onChange={handleChangeCondition}
  301. orgSlug={organization.slug}
  302. projectId={project.id}
  303. />
  304. )}
  305. </PanelBody>
  306. </StyledPanel>
  307. <NumberField
  308. label={`${t('Sample Rate')} \u0025`}
  309. name="sampleRate"
  310. onChange={value => {
  311. setData({...data, samplePercentage: !!value ? Number(value) : null});
  312. }}
  313. onKeyDown={(_value: string, e: KeyboardEvent) => {
  314. if (e.key === 'Enter') {
  315. handleSubmit();
  316. }
  317. }}
  318. placeholder={'\u0025'}
  319. value={samplePercentage}
  320. inline={false}
  321. hideControlState={!errors.sampleRate}
  322. error={errors.sampleRate}
  323. showHelpInTooltip
  324. stacked
  325. required
  326. />
  327. </Fields>
  328. </Body>
  329. <Footer>
  330. <ButtonBar gap={1}>
  331. <Button onClick={closeModal}>{t('Cancel')}</Button>
  332. <Button
  333. priority="primary"
  334. onClick={handleSubmit}
  335. title={submitDisabled ? t('Required fields must be filled out') : undefined}
  336. disabled={isSaving || submitDisabled}
  337. >
  338. {t('Save Rule')}
  339. </Button>
  340. </ButtonBar>
  341. </Footer>
  342. </Fragment>
  343. );
  344. }
  345. const Fields = styled('div')`
  346. display: grid;
  347. gap: ${space(2)};
  348. `;
  349. const StyledCompactSelect = styled(CompactSelect)`
  350. font-weight: 400;
  351. text-transform: none;
  352. `;
  353. const StyledPanelHeader = styled(PanelHeader)`
  354. padding-right: ${space(2)};
  355. `;
  356. const StyledPanel = styled(Panel)`
  357. margin-bottom: 0;
  358. `;
  359. const TriggerLabel = styled('div')`
  360. display: grid;
  361. grid-template-columns: repeat(2, max-content);
  362. align-items: center;
  363. gap: ${space(1)};
  364. `;
  365. const Description = styled(TextBlock)`
  366. margin: 0;
  367. `;