investigationRule.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import {useEffect} from 'react';
  2. import styled from '@emotion/styled';
  3. import moment from 'moment-timezone';
  4. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  5. import type {BaseButtonProps} from 'sentry/components/button';
  6. import {Button} from 'sentry/components/button';
  7. import ExternalLink from 'sentry/components/links/externalLink';
  8. import {Tooltip} from 'sentry/components/tooltip';
  9. import {IconQuestion, IconStack} from 'sentry/icons';
  10. import {t, tct} from 'sentry/locale';
  11. import {space} from 'sentry/styles/space';
  12. import type {Organization} from 'sentry/types/organization';
  13. import {trackAnalytics} from 'sentry/utils/analytics';
  14. import type EventView from 'sentry/utils/discover/eventView';
  15. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  16. import type {ApiQueryKey} from 'sentry/utils/queryClient';
  17. import {useApiQuery, useMutation, useQueryClient} from 'sentry/utils/queryClient';
  18. import type RequestError from 'sentry/utils/requestError/requestError';
  19. import useApi from 'sentry/utils/useApi';
  20. import useOrganization from 'sentry/utils/useOrganization';
  21. import {Datasource} from 'sentry/views/alerts/rules/metric/types';
  22. import {getQueryDatasource} from 'sentry/views/alerts/utils';
  23. import {hasDatasetSelector} from 'sentry/views/dashboards/utils';
  24. // Number of samples under which we can trigger an investigation rule
  25. const INVESTIGATION_MAX_SAMPLES_TRIGGER = 5;
  26. type Props = {
  27. buttonProps: BaseButtonProps;
  28. eventView: EventView;
  29. numSamples: number | null | undefined;
  30. };
  31. type PropsInternal = Props & {
  32. organization: Organization;
  33. };
  34. type CustomDynamicSamplingRule = {
  35. condition: Record<string, any>;
  36. dateAdded: string;
  37. endDate: string;
  38. numSamples: number;
  39. orgId: string;
  40. projects: number[];
  41. ruleId: number;
  42. sampleRate: number;
  43. startDate: string;
  44. };
  45. type CreateCustomRuleVariables = {
  46. organization: Organization;
  47. period: string | null;
  48. projects: number[];
  49. query: string;
  50. };
  51. function makeRuleExistsQueryKey(
  52. query: string,
  53. projects: number[],
  54. organization: Organization
  55. ): ApiQueryKey {
  56. // sort the projects to keep the query key invariant to the order of the projects
  57. const sortedProjects = [...projects].sort();
  58. return [
  59. `/organizations/${organization.slug}/dynamic-sampling/custom-rules/`,
  60. {
  61. query: {
  62. project: sortedProjects,
  63. query,
  64. },
  65. },
  66. ];
  67. }
  68. function hasTooFewSamples(numSamples: number | null | undefined) {
  69. // check if we have got the samples, but there are too few of them
  70. return (
  71. numSamples !== null &&
  72. numSamples !== undefined &&
  73. numSamples < INVESTIGATION_MAX_SAMPLES_TRIGGER
  74. );
  75. }
  76. function useGetExistingRule(
  77. query: string,
  78. projects: number[],
  79. organization: Organization,
  80. isTransactionQuery: boolean
  81. ) {
  82. const result = useApiQuery<CustomDynamicSamplingRule | '' | null>(
  83. makeRuleExistsQueryKey(query, projects, organization),
  84. {
  85. enabled: isTransactionQuery,
  86. staleTime: 0,
  87. // No retries for 4XX errors.
  88. // This makes the error feedback a lot faster, and there is no unnecessary network traffic.
  89. retry: (failureCount, error) => {
  90. if (failureCount >= 2) {
  91. return false;
  92. }
  93. if (error.status && error.status >= 400 && error.status < 500) {
  94. // don't retry 4xx errors (in theory 429 should be retried but not immediately)
  95. return false;
  96. }
  97. return true;
  98. },
  99. }
  100. );
  101. if (result.data === '') {
  102. // cleanup, the endpoint returns a 204 (with no body), change it to null
  103. result.data = null;
  104. }
  105. return result;
  106. }
  107. function useCreateInvestigationRuleMutation() {
  108. const api = useApi();
  109. const queryClient = useQueryClient();
  110. const {mutate} = useMutation<
  111. CustomDynamicSamplingRule,
  112. RequestError,
  113. CreateCustomRuleVariables
  114. >({
  115. mutationFn: variables => {
  116. const {organization} = variables;
  117. const endpoint = `/organizations/${organization.slug}/dynamic-sampling/custom-rules/`;
  118. return api.requestPromise(endpoint, {
  119. method: 'POST',
  120. data: variables,
  121. });
  122. },
  123. onSuccess: (_data, variables) => {
  124. addSuccessMessage(t('Successfully created investigation rule'));
  125. // invalidate the rule-exists query
  126. queryClient.invalidateQueries(
  127. makeRuleExistsQueryKey(
  128. variables.query,
  129. variables.projects,
  130. variables.organization
  131. )
  132. );
  133. trackAnalytics('dynamic_sampling.custom_rule_add', {
  134. organization: variables.organization,
  135. projects: variables.projects,
  136. query: variables.query,
  137. success: true,
  138. });
  139. },
  140. onError: (error, variables) => {
  141. if (error.status === 429) {
  142. addErrorMessage(
  143. t(
  144. 'You have reached the maximum number of concurrent investigation rules allowed'
  145. )
  146. );
  147. } else {
  148. addErrorMessage(t('Unable to create investigation rule'));
  149. }
  150. trackAnalytics('dynamic_sampling.custom_rule_add', {
  151. organization: variables.organization,
  152. projects: variables.projects,
  153. query: variables.query,
  154. success: false,
  155. });
  156. },
  157. retry: false,
  158. });
  159. return mutate;
  160. }
  161. const InvestigationInProgressNotification = styled('span')`
  162. font-size: ${p => p.theme.fontSizeMedium};
  163. color: ${p => p.theme.subText};
  164. font-weight: ${p => p.theme.fontWeightBold};
  165. display: inline-flex;
  166. align-items: center;
  167. gap: ${space(0.5)};
  168. `;
  169. function InvestigationRuleCreationInternal(props: PropsInternal) {
  170. const {organization, eventView} = props;
  171. const projects = [...props.eventView.project];
  172. const period = eventView.statsPeriod || null;
  173. const isTransactionsDataset =
  174. hasDatasetSelector(organization) &&
  175. eventView.dataset === DiscoverDatasets.TRANSACTIONS;
  176. const query = isTransactionsDataset
  177. ? appendEventTypeCondition(eventView.getQuery())
  178. : eventView.getQuery();
  179. const isTransactionQueryMissing =
  180. getQueryDatasource(query)?.source !== Datasource.TRANSACTION &&
  181. !isTransactionsDataset;
  182. const createInvestigationRule = useCreateInvestigationRuleMutation();
  183. const {
  184. data: rule,
  185. isFetching: isLoading,
  186. isError,
  187. } = useGetExistingRule(query, projects, organization, !isTransactionQueryMissing);
  188. const isBreakingRequestError = isError && !isTransactionQueryMissing;
  189. const isLikelyMoreNeeded = hasTooFewSamples(props.numSamples);
  190. useEffect(() => {
  191. if (isBreakingRequestError) {
  192. addErrorMessage(t('Unable to fetch investigation rule'));
  193. }
  194. }, [isBreakingRequestError]);
  195. if (isLoading) {
  196. return null;
  197. }
  198. if (isBreakingRequestError) {
  199. return null;
  200. }
  201. const isInvestigationRuleInProgress = !!rule;
  202. if (isInvestigationRuleInProgress) {
  203. // investigation rule in progress, just show a message
  204. const existingRule = rule as CustomDynamicSamplingRule;
  205. const ruleStartDate = new Date(existingRule.startDate);
  206. const now = new Date();
  207. const interval = moment.duration(now.getTime() - ruleStartDate.getTime()).humanize();
  208. return (
  209. <InvestigationInProgressNotification>
  210. {tct('Collecting samples since [interval] ago.', {interval})}
  211. <Tooltip
  212. isHoverable
  213. title={tct(
  214. 'A user has temporarily adjusted retention priorities, increasing the odds of getting events matching your search query. [link:Learn more.]',
  215. {
  216. link: (
  217. <ExternalLink href="https://docs.sentry.io/product/performance/retention-priorities/#investigation-mode" />
  218. ),
  219. }
  220. )}
  221. >
  222. <StyledIconQuestion size="sm" color="subText" />
  223. </Tooltip>
  224. </InvestigationInProgressNotification>
  225. );
  226. }
  227. // no investigation rule in progress, show a button to create one
  228. return (
  229. <Tooltip
  230. isHoverable
  231. title={
  232. isTransactionQueryMissing
  233. ? tct(
  234. 'If you filter by [code:event.type:transaction] we can adjust your retention priorities, increasing the odds of getting matching events. [link:Learn more.]',
  235. {
  236. code: <code />,
  237. link: (
  238. <ExternalLink href="https://docs.sentry.io/product/performance/retention-priorities/#investigation-mode" />
  239. ),
  240. }
  241. )
  242. : tct(
  243. 'We can find more events that match your search query by adjusting your retention priorities for an hour, increasing the odds of getting matching events. [link:Learn more.]',
  244. {
  245. link: (
  246. <ExternalLink href="https://docs.sentry.io/product/performance/retention-priorities/#investigation-mode" />
  247. ),
  248. }
  249. )
  250. }
  251. >
  252. <Button
  253. priority={isLikelyMoreNeeded ? 'primary' : 'default'}
  254. {...props.buttonProps}
  255. disabled={isTransactionQueryMissing}
  256. onClick={() => createInvestigationRule({organization, period, projects, query})}
  257. icon={<IconStack />}
  258. >
  259. {t('Get Samples')}
  260. </Button>
  261. </Tooltip>
  262. );
  263. }
  264. export function InvestigationRuleCreation(props: Props) {
  265. const organization = useOrganization();
  266. if (!organization.isDynamicallySampled) {
  267. return null;
  268. }
  269. return <InvestigationRuleCreationInternal {...props} organization={organization} />;
  270. }
  271. const StyledIconQuestion = styled(IconQuestion)`
  272. position: relative;
  273. top: 2px;
  274. `;
  275. function appendEventTypeCondition(query: string) {
  276. if (query.length > 0) {
  277. return `event.type:transaction (${query})`;
  278. }
  279. return 'event.type:transaction';
  280. }