Browse Source

feat(metrics):Ui for investigation rules (#57452)

Radu Woinaroski 1 year ago
parent
commit
7adecabeaf

+ 1 - 0
static/app/components/button.tsx

@@ -580,6 +580,7 @@ const LinkButton = Button as React.ComponentType<LinkButtonProps>;
 export {
   Button,
   ButtonProps,
+  BaseButtonProps,
   LinkButton,
   LinkButtonProps,
 

+ 22 - 6
static/app/components/discover/transactionsList.tsx

@@ -7,6 +7,7 @@ import GuideAnchor from 'sentry/components/assistant/guideAnchor';
 import {Button} from 'sentry/components/button';
 import {CompactSelect} from 'sentry/components/compactSelect';
 import DiscoverButton from 'sentry/components/discoverButton';
+import {InvestigationRuleCreation} from 'sentry/components/dynamicSampling/investigationRule';
 import Pagination, {CursorHandler} from 'sentry/components/pagination';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -262,7 +263,7 @@ class _TransactionsList extends Component<Props> {
     return generatePerformanceTransactionEventsView?.() ?? this.getEventView();
   }
 
-  renderHeader(): React.ReactNode {
+  renderHeader({numSamples}: {numSamples: number | null | undefined}): React.ReactNode {
     const {
       organization,
       selected,
@@ -272,6 +273,7 @@ class _TransactionsList extends Component<Props> {
       handleOpenInDiscoverClick,
       showTransactions,
       breakdown,
+      eventView,
     } = this.props;
 
     return (
@@ -284,6 +286,14 @@ class _TransactionsList extends Component<Props> {
             onChange={opt => handleDropdownChange(opt.value)}
           />
         </div>
+        <div>
+          <InvestigationRuleCreation
+            buttonProps={{size: 'xs'}}
+            eventView={eventView}
+            organization={organization}
+            numSamples={numSamples}
+          />
+        </div>
         {!this.isTrend() &&
           (handleOpenAllEventsClick ? (
             <GuideAnchor target="release_transactions_open_in_transaction_events">
@@ -338,7 +348,7 @@ class _TransactionsList extends Component<Props> {
     const cursor = decodeScalar(location.query?.[cursorName]);
     const tableCommonProps: Omit<
       TableRenderProps,
-      'isLoading' | 'pageLinks' | 'tableData'
+      'isLoading' | 'pageLinks' | 'tableData' | 'header'
     > = {
       handleCellAction,
       referrer,
@@ -349,7 +359,6 @@ class _TransactionsList extends Component<Props> {
       titles,
       generateLink,
       useAggregateAlias: false,
-      header: this.renderHeader(),
       target: 'transactions_table',
       paginationCursorSize: 'xs',
       onCursor: this.handleCursor,
@@ -357,7 +366,13 @@ class _TransactionsList extends Component<Props> {
 
     if (forceLoading) {
       return (
-        <TableRender {...tableCommonProps} isLoading pageLinks={null} tableData={null} />
+        <TableRender
+          {...tableCommonProps}
+          isLoading
+          pageLinks={null}
+          tableData={null}
+          header={this.renderHeader({numSamples: null})}
+        />
       );
     }
 
@@ -376,6 +391,7 @@ class _TransactionsList extends Component<Props> {
             isLoading={isLoading}
             pageLinks={pageLinks}
             tableData={tableData}
+            header={this.renderHeader({numSamples: tableData?.data?.length ?? null})}
           />
         )}
       </DiscoverQuery>
@@ -414,7 +430,7 @@ class _TransactionsList extends Component<Props> {
             pageLinks={pageLinks}
             onCursor={this.handleCursor}
             paginationCursorSize="sm"
-            header={this.renderHeader()}
+            header={this.renderHeader({numSamples: null})}
             titles={['transaction', 'percentage', 'difference']}
             columnOrder={decodeColumnOrder([
               {field: 'transaction'},
@@ -445,7 +461,7 @@ class _TransactionsList extends Component<Props> {
 
 const Header = styled('div')`
   display: grid;
-  grid-template-columns: 1fr auto auto;
+  grid-template-columns: 1fr auto auto auto;
   margin-bottom: ${space(1)};
   align-items: center;
 `;

+ 228 - 0
static/app/components/dynamicSampling/investigationRule.tsx

@@ -0,0 +1,228 @@
+import styled from '@emotion/styled';
+import moment from 'moment';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import Feature from 'sentry/components/acl/feature';
+import {BaseButtonProps, Button} from 'sentry/components/button';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconQuestion, IconStack} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {OrganizationSummary} from 'sentry/types';
+import EventView from 'sentry/utils/discover/eventView';
+import {
+  ApiQueryKey,
+  useApiQuery,
+  useMutation,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+
+// Number of samples under which we can trigger an investigation rule
+const INVESTIGATION_MAX_SAMPLES_TRIGGER = 5;
+
+type Props = {
+  buttonProps: BaseButtonProps;
+  eventView: EventView;
+  numSamples: number | null | undefined;
+  organization: OrganizationSummary;
+};
+
+type CustomDynamicSamplingRule = {
+  condition: Record<string, any>;
+  dateAdded: string;
+  endDate: string;
+  numSamples: number;
+  orgId: string;
+  projects: number[];
+  ruleId: number;
+  sampleRate: number;
+  startDate: string;
+};
+type CreateCustomRuleVariables = {
+  organization: OrganizationSummary;
+  period: string | null;
+  projects: number[];
+  query: string;
+};
+
+function makeRuleExistsQueryKey(
+  query: string,
+  projects: number[],
+  organization: OrganizationSummary
+): ApiQueryKey {
+  // sort the projects to keep the query key invariant to the order of the projects
+  const sortedProjects = [...projects].sort();
+  return [
+    `/organizations/${organization.slug}/dynamic-sampling/custom-rules/`,
+    {
+      query: {
+        project: sortedProjects,
+        query,
+      },
+    },
+  ];
+}
+
+function hasTooFewSamples(numSamples: number | null | undefined) {
+  // check if we have got the samples, but there are too few of them
+  return (
+    numSamples !== null &&
+    numSamples !== undefined &&
+    numSamples < INVESTIGATION_MAX_SAMPLES_TRIGGER
+  );
+}
+
+function useGetExistingRule(
+  query: string,
+  projects: number[],
+  organization: OrganizationSummary,
+  numSamples: number | null | undefined
+) {
+  const enabled = hasTooFewSamples(numSamples);
+
+  const result = useApiQuery<CustomDynamicSamplingRule | '' | null>(
+    makeRuleExistsQueryKey(query, projects, organization),
+    {
+      staleTime: 0,
+      enabled,
+    }
+  );
+
+  if (result.data === '') {
+    // cleanup, the endpoint returns a 204 (with no body), change it to null
+    result.data = null;
+  }
+
+  return result;
+}
+
+function useCreateInvestigationRuleMutation(vars: CreateCustomRuleVariables) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+  const {mutate} = useMutation<
+    CustomDynamicSamplingRule,
+    Error,
+    CreateCustomRuleVariables
+  >({
+    mutationFn: (variables: CreateCustomRuleVariables) => {
+      const {organization} = variables;
+      const endpoint = `/organizations/${organization.slug}/dynamic-sampling/custom-rules/`;
+      return api.requestPromise(endpoint, {
+        method: 'POST',
+        data: variables,
+      });
+    },
+    onSuccess: (_data: CustomDynamicSamplingRule) => {
+      addSuccessMessage(t('Successfully created investigation rule'));
+      // invalidate the rule-exists query
+      queryClient.invalidateQueries(
+        makeRuleExistsQueryKey(vars.query, vars.projects, vars.organization)
+      );
+    },
+    onError: (_error: Error) => {
+      addErrorMessage(t('Unable to create investigation rule'));
+    },
+  });
+  return mutate;
+}
+
+const InvestigationInProgressNotification = styled('span')`
+  margin: ${space(1.5)};
+  font-size: ${p => p.theme.fontSizeMedium};
+  color: ${p => p.theme.subText};
+  font-weight: 600;
+  display: inline-flex;
+  align-items: center;
+  gap: ${space(0.5)};
+`;
+
+function InvestigationRuleCreationInternal(props: Props) {
+  const projects = [...props.eventView.project];
+  const organization = props.organization;
+  const period = props.eventView.statsPeriod || null;
+  const query = props.eventView.getQuery();
+  const createInvestigationRule = useCreateInvestigationRuleMutation({
+    query,
+    projects,
+    organization,
+    period,
+  });
+  const request = useGetExistingRule(query, projects, organization, props.numSamples);
+
+  if (!hasTooFewSamples(props.numSamples)) {
+    // no results yet (we can't take a decision) or enough results,
+    // we don't need investigation rule UI
+    return null;
+  }
+  if (request.isLoading) {
+    return null;
+  }
+
+  if (request.error !== null) {
+    const errorResponse = t('Unable to fetch investigation rule');
+    addErrorMessage(errorResponse);
+    return null;
+  }
+
+  const rule = request.data;
+  const haveInvestigationRuleInProgress = rule !== null;
+
+  if (haveInvestigationRuleInProgress) {
+    // investigation rule in progress, just show a message
+    const existingRule = rule as CustomDynamicSamplingRule;
+    const ruleStartDate = new Date(existingRule.startDate);
+    const now = new Date();
+    const interval = moment.duration(now.getTime() - ruleStartDate.getTime()).humanize();
+
+    return (
+      <InvestigationInProgressNotification>
+        {tct('Collecting samples since [interval]  ago.', {interval})}
+
+        <Tooltip
+          isHoverable
+          title={tct(
+            'A user has temporarily adjusted retention priorities, increasing the odds of getting events matching your search query. [link:Learn more.]',
+            // TODO find out where this link is pointing to
+            {
+              link: <ExternalLink href="https://docs.sentry.io" />,
+            }
+          )}
+        >
+          <IconQuestion size="xs" color="subText" />
+        </Tooltip>
+      </InvestigationInProgressNotification>
+    );
+  }
+
+  // no investigation rule in progress, show a button to create one
+  return (
+    <Tooltip
+      isHoverable
+      title={tct(
+        '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.]',
+        // TODO find out where this link is pointing to
+        {
+          link: <ExternalLink href="https://docs.sentry.io" />,
+        }
+      )}
+    >
+      <Button
+        {...props.buttonProps}
+        onClick={() => createInvestigationRule({organization, period, projects, query})}
+        icon={<IconStack size="xs" />}
+      >
+        {t('Get Samples')}
+      </Button>
+    </Tooltip>
+  );
+}
+
+export function InvestigationRuleCreation(props: Props) {
+  return (
+    <Feature features={['investigation-bias']}>
+      <InvestigationRuleCreationInternal {...props} />
+    </Feature>
+  );
+}

+ 7 - 0
static/app/views/discover/results.spec.tsx

@@ -205,6 +205,13 @@ function renderMockRequests() {
     },
   });
 
+  MockApiClient.addMockResponse({
+    url: '/organizations/org-slug/dynamic-sampling/custom-rules/',
+    method: 'GET',
+    statusCode: 204,
+    body: '',
+  });
+
   return {
     eventsStatsMock,
     eventsMetaMock,

+ 11 - 2
static/app/views/discover/table/tableActions.tsx

@@ -6,6 +6,7 @@ import FeatureDisabled from 'sentry/components/acl/featureDisabled';
 import GuideAnchor from 'sentry/components/assistant/guideAnchor';
 import {Button} from 'sentry/components/button';
 import DataExport, {ExportQueryType} from 'sentry/components/dataExport';
+import {InvestigationRuleCreation} from 'sentry/components/dynamicSampling/investigationRule';
 import {Hovercard} from 'sentry/components/hovercard';
 import {IconDownload, IconStack, IconTag} from 'sentry/icons';
 import {t} from 'sentry/locale';
@@ -87,6 +88,7 @@ function renderAsyncExportButton(canEdit: boolean, props: Props) {
     </DataExport>
   );
 }
+
 // Placate eslint proptype checking
 
 function renderEditButton(canEdit: boolean, props: Props) {
@@ -105,6 +107,7 @@ function renderEditButton(canEdit: boolean, props: Props) {
     </GuideAnchor>
   );
 }
+
 // Placate eslint proptype checking
 
 function renderSummaryButton({onChangeShowTags, showTags}: Props) {
@@ -148,9 +151,15 @@ function FeatureWrapper(props: FeatureWrapperProps) {
   );
 }
 
-function HeaderActions(props: Props) {
+function TableActions(props: Props) {
   return (
     <Fragment>
+      <InvestigationRuleCreation
+        {...props}
+        buttonProps={{size: 'sm'}}
+        numSamples={props.tableData?.data?.length}
+        key="investigationRuleCreation"
+      />
       <FeatureWrapper {...props} key="edit">
         {renderEditButton}
       </FeatureWrapper>
@@ -162,4 +171,4 @@ function HeaderActions(props: Props) {
   );
 }
 
-export default HeaderActions;
+export default TableActions;

+ 8 - 0
static/app/views/discover/table/tableView.spec.tsx

@@ -106,6 +106,13 @@ describe('TableView > CellActions', function () {
         },
       ],
     };
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dynamic-sampling/custom-rules/',
+      method: 'GET',
+      statusCode: 204,
+      body: '',
+    });
   });
 
   afterEach(() => {
@@ -346,6 +353,7 @@ describe('TableView > CellActions', function () {
     const orgWithFeature = Organization({
       projects: [TestStubs.Project()],
     });
+
     render(
       <TableView
         organization={orgWithFeature}