Browse Source

ref(sampling): Duplicate server side sampling folder and give it a new name - (#39917)

Priscila Oliveira 2 years ago
parent
commit
9b5c4f0699

+ 138 - 0
fixtures/js-stubs/dynamicSamplingConfig.js

@@ -0,0 +1,138 @@
+import {
+  SamplingConditionOperator,
+  SamplingInnerName,
+  SamplingInnerOperator,
+  SamplingRuleType,
+} from 'sentry/types/sampling';
+
+export function DynamicSamplingConfig(params = {}) {
+  return {
+    uniformRule: {
+      sampleRate: 0.5,
+      type: SamplingRuleType.TRACE,
+      active: false,
+      condition: {
+        op: SamplingConditionOperator.AND,
+        inner: [],
+      },
+      id: 1,
+    },
+    specificRule: {
+      sampleRate: 0.2,
+      active: false,
+      type: SamplingRuleType.TRACE,
+      condition: {
+        op: SamplingConditionOperator.AND,
+        inner: [
+          {
+            op: SamplingInnerOperator.GLOB_MATCH,
+            name: SamplingInnerName.TRACE_RELEASE,
+            value: ['1.2.2'],
+          },
+        ],
+      },
+      id: 2,
+    },
+    samplingSdkVersions: [
+      {
+        project: 'javascript',
+        latestSDKVersion: '1.0.3',
+        latestSDKName: 'sentry.javascript.react',
+        isSendingSampleRate: true,
+        isSendingSource: true,
+        isSupportedPlatform: true,
+      },
+      {
+        project: 'sentry',
+        latestSDKVersion: '1.0.2',
+        latestSDKName: 'sentry.python',
+        isSendingSampleRate: false,
+        isSendingSource: false,
+        isSupportedPlatform: true,
+      },
+      {
+        project: 'java',
+        latestSDKVersion: '1.0.2',
+        latestSDKName: 'sentry.java',
+        isSendingSampleRate: true,
+        isSendingSource: false,
+        isSupportedPlatform: true,
+      },
+      {
+        project: 'angular',
+        latestSDKVersion: '1.0.2',
+        latestSDKName: 'sentry.javascript.angular',
+        isSendingSampleRate: false,
+        isSendingSource: false,
+        isSupportedPlatform: false,
+      },
+    ],
+    samplingDistribution: {
+      projectBreakdown: [
+        {
+          project: 'javascript',
+          projectId: 1,
+          'count()': 888,
+        },
+        {
+          project: 'sentry',
+          projectId: 2,
+          'count()': 100,
+        },
+      ],
+      parentProjectBreakdown: [
+        {
+          percentage: 10,
+          project: 'parent-project',
+          projectId: 10,
+        },
+      ],
+      sampleSize: 100,
+      startTimestamp: '2017-08-04T07:52:11Z',
+      endTimestamp: '2017-08-05T07:52:11Z',
+    },
+    projects: [
+      TestStubs.Project({
+        name: 'javascript',
+        slug: 'javascript',
+        id: 1,
+      }),
+      TestStubs.Project({
+        name: 'sentry',
+        slug: 'sentry',
+        platform: 'python',
+        id: 2,
+      }),
+      TestStubs.Project({
+        id: 4,
+        dynamicSampling: {
+          rules: [
+            {
+              sampleRate: 1,
+              type: 'trace',
+              active: false,
+              condition: {
+                op: 'and',
+                inner: [],
+              },
+              id: 1,
+            },
+          ],
+        },
+      }),
+    ],
+    recommendedSdkUpgrades: [
+      {
+        project: TestStubs.Project({
+          name: 'sentry',
+          slug: 'sentry',
+          platform: 'python',
+          id: 2,
+        }),
+        latestSDKVersion: '1.0.2',
+        latestSDKName: 'sentry.python',
+      },
+    ],
+    ...params,
+  };
+}

+ 11 - 0
fixtures/js-stubs/outcomes.js

@@ -1,3 +1,5 @@
+import {Outcome} from 'sentry/types';
+
 export function Outcomes() {
   return {
     start: '2022-07-02T19:00:00Z',
@@ -319,3 +321,12 @@ export function OutcomesWithLowProcessedEvents() {
     ],
   };
 }
+
+export function OutcomesWithoutClientDiscarded() {
+  return {
+    ...TestStubs.OutcomesWithReason(),
+    groups: TestStubs.OutcomesWithReason().groups.filter(
+      group => group.by.outcome !== Outcome.CLIENT_DISCARD
+    ),
+  };
+}

+ 2 - 0
fixtures/js-stubs/types.tsx

@@ -32,6 +32,7 @@ type TestStubFixtures = {
   DetailedEvents: SimpleStub;
   DiscoverSavedQuery: OverridableStub;
   DocIntegration: OverridableStub;
+  DynamicSamplingConfig: OverridableStub;
   Entries: SimpleStub;
   Environments: OverridableStub;
   Event: OverridableStub;
@@ -80,6 +81,7 @@ type TestStubFixtures = {
   Outcomes: SimpleStub;
   OutcomesWithLowProcessedEvents: SimpleStub;
   OutcomesWithReason: SimpleStub;
+  OutcomesWithoutClientDiscarded: SimpleStub;
   PhabricatorCreate: SimpleStub;
   PhabricatorPlugin: SimpleStub;
   PlatformExternalIssue: OverridableStub;

+ 2 - 6
static/app/routes.tsx

@@ -470,15 +470,11 @@ function buildRoutes() {
       </Route>
       <Route path="dynamic-sampling/" name={t('Dynamic Sampling')}>
         <IndexRoute
-          component={make(
-            () => import('sentry/views/settings/project/server-side-sampling')
-          )}
+          component={make(() => import('sentry/views/settings/project/dynamicSampling'))}
         />
         <Route
           path="rules/:rule/"
-          component={make(
-            () => import('sentry/views/settings/project/server-side-sampling')
-          )}
+          component={make(() => import('sentry/views/settings/project/dynamicSampling'))}
         />
       </Route>
       <Redirect from="server-side-sampling/" to="dynamic-sampling/" />

+ 106 - 0
static/app/views/settings/project/dynamicSampling/draggableRuleList.tsx

@@ -0,0 +1,106 @@
+import {useState} from 'react';
+import {createPortal} from 'react-dom';
+import {DndContext, DragOverlay} from '@dnd-kit/core';
+import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable';
+
+import {SamplingRule} from 'sentry/types/sampling';
+
+import {DraggableRuleListItem, DraggableRuleListItemProps} from './draggableRuleListItem';
+import {
+  DraggableRuleListSortableItem,
+  SortableItemProps,
+} from './draggableRuleListSortableItem';
+import {isUniformRule} from './utils';
+
+export type DraggableRuleListUpdateItemsProps = {
+  activeIndex: string;
+  overIndex: string;
+  reorderedItems: Array<string>;
+};
+
+type Props = Pick<SortableItemProps, 'disabled' | 'wrapperStyle'> &
+  Pick<DraggableRuleListItemProps, 'renderItem'> & {
+    items: Array<
+      Omit<SamplingRule, 'id'> & {
+        id: string;
+      }
+    >;
+    onUpdateItems: (props: DraggableRuleListUpdateItemsProps) => void;
+  };
+
+type State = {
+  activeId?: string;
+};
+
+export function DraggableRuleList({
+  items,
+  onUpdateItems,
+  renderItem,
+  disabled = false,
+  wrapperStyle = () => ({}),
+}: Props) {
+  const [state, setState] = useState<State>({});
+
+  const itemIds = items.map(item => item.id);
+  const getIndex = itemIds.indexOf.bind(itemIds);
+  const activeIndex = state.activeId ? getIndex(state.activeId) : -1;
+
+  return (
+    <DndContext
+      onDragStart={({active}) => {
+        if (!active) {
+          return;
+        }
+
+        setState({activeId: active.id});
+      }}
+      onDragEnd={({over}) => {
+        setState({activeId: undefined});
+
+        if (over) {
+          const overIndex = getIndex(over.id);
+
+          if (activeIndex !== overIndex) {
+            onUpdateItems({
+              activeIndex,
+              overIndex,
+              reorderedItems: arrayMove(itemIds, activeIndex, overIndex),
+            });
+          }
+        }
+      }}
+      onDragCancel={() => setState({activeId: undefined})}
+    >
+      <SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
+        {itemIds.map((itemId, index) => (
+          <DraggableRuleListSortableItem
+            key={itemId}
+            id={itemId}
+            index={index}
+            renderItem={renderItem}
+            disabled={
+              disabled || isUniformRule({...items[index], id: Number(items[index].id)})
+            }
+            wrapperStyle={wrapperStyle}
+          />
+        ))}
+      </SortableContext>
+      {createPortal(
+        <DragOverlay>
+          {state.activeId ? (
+            <DraggableRuleListItem
+              value={itemIds[activeIndex]}
+              renderItem={renderItem}
+              wrapperStyle={wrapperStyle({
+                index: activeIndex,
+                isDragging: true,
+                isSorting: false,
+              })}
+            />
+          ) : null}
+        </DragOverlay>,
+        document.body
+      )}
+    </DndContext>
+  );
+}

+ 109 - 0
static/app/views/settings/project/dynamicSampling/draggableRuleListItem.tsx

@@ -0,0 +1,109 @@
+import {DraggableSyntheticListeners} from '@dnd-kit/core';
+import {useSortable} from '@dnd-kit/sortable';
+import {Transform} from '@dnd-kit/utilities';
+import styled from '@emotion/styled';
+
+type UseSortableOutputProps = ReturnType<typeof useSortable>;
+
+export type DraggableRuleListItemProps = {
+  renderItem(args: {
+    dragging: boolean;
+    sorting: boolean;
+    value: DraggableRuleListItemProps['value'];
+    attributes?: DraggableRuleListItemProps['attributes'];
+    index?: DraggableRuleListItemProps['index'];
+    listeners?: DraggableRuleListItemProps['listeners'];
+    transform?: DraggableRuleListItemProps['transform'];
+    transition?: DraggableRuleListItemProps['transition'];
+  }): React.ReactElement | null;
+  value: React.ReactNode;
+  attributes?: UseSortableOutputProps['attributes'];
+  dragging?: boolean;
+  forwardRef?: React.Ref<HTMLDivElement>;
+  index?: number;
+  listeners?: DraggableSyntheticListeners;
+  sorting?: boolean;
+  transform?: Transform | null;
+  transition?: string | null;
+  wrapperStyle?: React.CSSProperties;
+};
+
+export function DraggableRuleListItem({
+  value,
+  dragging,
+  index,
+  transform,
+  listeners,
+  sorting,
+  transition,
+  forwardRef,
+  attributes,
+  renderItem,
+  wrapperStyle,
+}: DraggableRuleListItemProps) {
+  return (
+    <Wrapper
+      data-test-id="sampling-rule"
+      ref={forwardRef}
+      style={
+        {
+          ...wrapperStyle,
+          transition,
+          '--translate-x': transform ? `${Math.round(transform.x)}px` : undefined,
+          '--translate-y': transform ? `${Math.round(transform.y)}px` : undefined,
+          '--scale-x': transform?.scaleX ? `${transform.scaleX}` : undefined,
+          '--scale-y': transform?.scaleY ? `${transform.scaleY}` : undefined,
+        } as React.CSSProperties
+      }
+    >
+      <InnerWrapper>
+        {renderItem({
+          dragging: Boolean(dragging),
+          sorting: Boolean(sorting),
+          listeners,
+          transform,
+          transition,
+          value,
+          index,
+          attributes,
+        })}
+      </InnerWrapper>
+    </Wrapper>
+  );
+}
+
+const boxShadowBorder = '0 0 0 calc(1px / var(--scale-x, 1)) rgba(63, 63, 68, 0.05)';
+const boxShadowCommon = '0 1px calc(3px / var(--scale-x, 1)) 0 rgba(34, 33, 81, 0.15)';
+const boxShadow = `${boxShadowBorder}, ${boxShadowCommon}`;
+
+const Wrapper = styled('div')`
+  transform: translate3d(var(--translate-x, 0), var(--translate-y, 0), 0)
+    scaleX(var(--scale-x, 1)) scaleY(var(--scale-y, 1));
+  transform-origin: 0 0;
+  touch-action: manipulation;
+  --box-shadow: ${boxShadow};
+  --box-shadow-picked-up: ${boxShadowBorder}, -1px 0 15px 0 rgba(34, 33, 81, 0.01),
+    0px 15px 15px 0 rgba(34, 33, 81, 0.25);
+`;
+
+const InnerWrapper = styled('div')`
+  background-color: ${p => p.theme.background};
+
+  animation: pop 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22);
+  box-shadow: var(--box-shadow-picked-up);
+  opacity: 1;
+
+  :focus {
+    box-shadow: 0 0px 4px 1px rgba(76, 159, 254, 1), ${boxShadow};
+  }
+
+  @keyframes pop {
+    0% {
+      transform: scale(1);
+      box-shadow: var(--box-shadow);
+    }
+    100% {
+      box-shadow: var(--box-shadow-picked-up);
+    }
+  }
+`;

+ 50 - 0
static/app/views/settings/project/dynamicSampling/draggableRuleListSortableItem.tsx

@@ -0,0 +1,50 @@
+import {useSortable} from '@dnd-kit/sortable';
+
+import {DraggableRuleListItem} from './draggableRuleListItem';
+
+export type SortableItemProps = Pick<
+  React.ComponentProps<typeof DraggableRuleListItem>,
+  'renderItem' | 'index'
+> & {
+  id: string;
+  index: number;
+  wrapperStyle(args: {
+    index: number;
+    isDragging: boolean;
+    isSorting: boolean;
+  }): React.CSSProperties;
+  disabled?: boolean;
+};
+
+export function DraggableRuleListSortableItem({
+  id,
+  index,
+  renderItem,
+  disabled,
+  wrapperStyle,
+}: SortableItemProps) {
+  const {
+    attributes,
+    isSorting,
+    isDragging,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+  } = useSortable({id, disabled});
+
+  return (
+    <DraggableRuleListItem
+      forwardRef={setNodeRef}
+      value={id}
+      sorting={isSorting}
+      renderItem={renderItem}
+      index={index}
+      transform={transform}
+      transition={transition}
+      listeners={listeners}
+      attributes={attributes}
+      wrapperStyle={wrapperStyle({index, isDragging, isSorting})}
+    />
+  );
+}

+ 603 - 0
static/app/views/settings/project/dynamicSampling/dynamicSamping.spec.tsx

@@ -0,0 +1,603 @@
+import {Fragment} from 'react';
+import {
+  createMemoryHistory,
+  IndexRoute,
+  InjectedRouter,
+  Route,
+  Router,
+  RouterContext,
+} from 'react-router';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  render,
+  screen,
+  userEvent,
+  waitFor,
+  within,
+} from 'sentry-test/reactTestingLibrary';
+
+import GlobalModal from 'sentry/components/globalModal';
+import {ServerSideSamplingStore} from 'sentry/stores/serverSideSamplingStore';
+import {Organization, Project} from 'sentry/types';
+import {SamplingSdkVersion} from 'sentry/types/sampling';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import {RouteContext} from 'sentry/views/routeContext';
+import DynamicSampling from 'sentry/views/settings/project/dynamicSampling';
+import {SERVER_SIDE_SAMPLING_DOC_LINK} from 'sentry/views/settings/project/dynamicSampling/utils';
+
+import {samplingBreakdownTitle} from './samplingBreakdown.spec';
+
+const features = [
+  'server-side-sampling',
+  'server-side-sampling-ui',
+  'dynamic-sampling-basic',
+  'dynamic-sampling-total-transaction-packaging',
+];
+
+function TestComponent({
+  router,
+  project,
+  organization,
+  withModal,
+}: {
+  organization: Organization;
+  project: Project;
+  router?: InjectedRouter;
+  withModal?: boolean;
+}) {
+  const children = (
+    <Fragment>
+      {withModal && <GlobalModal />}
+      <OrganizationContext.Provider value={organization}>
+        <DynamicSampling project={project} />
+      </OrganizationContext.Provider>
+    </Fragment>
+  );
+
+  if (router) {
+    return (
+      <RouteContext.Provider
+        value={{
+          router,
+          location: router.location,
+          params: {
+            orgId: organization.slug,
+            projectId: project.slug,
+          },
+          routes: [],
+        }}
+      >
+        {children}
+      </RouteContext.Provider>
+    );
+  }
+
+  return children;
+}
+
+function renderMockRequests({
+  organizationSlug,
+  projectSlug,
+  mockedSdkVersionsResponse = TestStubs.DynamicSamplingConfig().samplingSdkVersions,
+}: {
+  organizationSlug: Organization['slug'];
+  projectSlug: Project['slug'];
+  mockedSdkVersionsResponse?: SamplingSdkVersion[];
+}) {
+  const distribution = MockApiClient.addMockResponse({
+    url: `/projects/${organizationSlug}/${projectSlug}/dynamic-sampling/distribution/`,
+    method: 'GET',
+    body: TestStubs.DynamicSamplingConfig().samplingDistribution,
+  });
+
+  const sdkVersions = MockApiClient.addMockResponse({
+    url: `/organizations/${organizationSlug}/dynamic-sampling/sdk-versions/`,
+    method: 'GET',
+    body: mockedSdkVersionsResponse,
+  });
+
+  const projects = MockApiClient.addMockResponse({
+    url: `/organizations/${organizationSlug}/projects/`,
+    method: 'GET',
+    body: TestStubs.DynamicSamplingConfig().samplingDistribution.projectBreakdown!.map(
+      p => TestStubs.Project({id: p.projectId, slug: p.project})
+    ),
+  });
+
+  const statsV2 = MockApiClient.addMockResponse({
+    url: `/organizations/${organizationSlug}/stats_v2/`,
+    method: 'GET',
+    body: TestStubs.Outcomes(),
+  });
+
+  return {distribution, sdkVersions, projects, statsV2};
+}
+
+describe('Dynamic Sampling', function () {
+  beforeEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('renders rules panel', async function () {
+    const {organization, router, project} = initializeOrg({
+      ...initializeOrg(),
+      organization: {
+        ...initializeOrg().organization,
+        features,
+      },
+      projects: [
+        TestStubs.Project({
+          dynamicSampling: {
+            rules: [{...TestStubs.DynamicSamplingConfig().uniformRule, sampleRate: 1}],
+          },
+        }),
+      ],
+    });
+
+    renderMockRequests({
+      organizationSlug: organization.slug,
+      projectSlug: project.slug,
+    });
+
+    const {container} = render(
+      <TestComponent router={router} organization={organization} project={project} />
+    );
+
+    // Assert that project breakdown is there
+    expect(await screen.findByText(samplingBreakdownTitle)).toBeInTheDocument();
+
+    // Rule Panel Header
+    expect(screen.getByText('Operator')).toBeInTheDocument();
+    expect(screen.getByText('Condition')).toBeInTheDocument();
+    expect(screen.getByText('Rate')).toBeInTheDocument();
+    expect(screen.getByText('Active')).toBeInTheDocument();
+
+    // Rule Panel Content
+    expect(screen.getAllByTestId('sampling-rule').length).toBe(1);
+    expect(screen.queryByLabelText('Drag Rule')).not.toBeInTheDocument();
+    expect(screen.getByTestId('sampling-rule')).toHaveTextContent('If');
+    expect(screen.getByTestId('sampling-rule')).toHaveTextContent('All');
+    expect(screen.getByTestId('sampling-rule')).toHaveTextContent('100%');
+    expect(screen.getByLabelText('Activate Rule')).toBeInTheDocument();
+    expect(screen.getByLabelText('Actions')).toBeInTheDocument();
+
+    // Rule Panel Footer
+    expect(screen.getByText('Add Rule')).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: 'Read Docs'})).toHaveAttribute(
+      'href',
+      SERVER_SIDE_SAMPLING_DOC_LINK
+    );
+
+    expect(container).toSnapshot();
+  });
+
+  it('does not let you delete the base rule', async function () {
+    const {organization, router, project} = initializeOrg({
+      ...initializeOrg(),
+      organization: {
+        ...initializeOrg().organization,
+        features,
+      },
+      projects: [
+        TestStubs.Project({
+          dynamicSampling: {
+            rules: [
+              {
+                sampleRate: 0.2,
+                type: 'trace',
+                active: false,
+                condition: {
+                  op: 'and',
+                  inner: [
+                    {
+                      op: 'glob',
+                      name: 'trace.release',
+                      value: ['1.2.3'],
+                    },
+                  ],
+                },
+                id: 2,
+              },
+              {
+                sampleRate: 0.2,
+                type: 'trace',
+                active: false,
+                condition: {
+                  op: 'and',
+                  inner: [],
+                },
+                id: 1,
+              },
+            ],
+            next_id: 3,
+          },
+        }),
+      ],
+    });
+
+    renderMockRequests({
+      organizationSlug: organization.slug,
+      projectSlug: project.slug,
+    });
+
+    render(
+      <TestComponent router={router} organization={organization} project={project} />
+    );
+
+    // Assert that project breakdown is there (avoids 'act' warnings)
+    expect(await screen.findByText(samplingBreakdownTitle)).toBeInTheDocument();
+
+    userEvent.click(screen.getAllByLabelText('Actions')[0]);
+    expect(screen.getByRole('menuitemradio', {name: 'Delete'})).toHaveAttribute(
+      'aria-disabled',
+      'false'
+    );
+
+    userEvent.click(screen.getAllByLabelText('Actions')[0]);
+    userEvent.click(screen.getAllByLabelText('Actions')[1]);
+    expect(screen.getByRole('menuitemradio', {name: 'Delete'})).toHaveAttribute(
+      'aria-disabled',
+      'true'
+    );
+  });
+
+  it('display "update sdk versions" alert and open "recommended next step" modal', async function () {
+    const {organization, router, projects} = initializeOrg({
+      ...initializeOrg(),
+      organization: {
+        ...initializeOrg().organization,
+        features,
+      },
+      projects: [
+        TestStubs.Project({
+          name: 'javascript',
+          slug: 'javascript',
+          id: 1,
+        }),
+        TestStubs.Project({
+          name: 'sentry',
+          slug: 'sentry',
+          platform: 'python',
+          id: 2,
+        }),
+        TestStubs.Project({
+          id: 4,
+          dynamicSampling: {
+            rules: [
+              {
+                sampleRate: 1,
+                type: 'trace',
+                active: false,
+                condition: {
+                  op: 'and',
+                  inner: [],
+                },
+                id: 1,
+              },
+            ],
+          },
+        }),
+      ],
+    });
+
+    const mockRequests = renderMockRequests({
+      organizationSlug: organization.slug,
+      projectSlug: projects[2].slug,
+    });
+
+    render(
+      <TestComponent
+        organization={organization}
+        project={projects[2]}
+        router={router}
+        withModal
+      />
+    );
+
+    expect(mockRequests.distribution).toHaveBeenCalled();
+
+    await waitFor(() => {
+      expect(mockRequests.sdkVersions).toHaveBeenCalled();
+    });
+
+    const recommendedSdkUpgradesAlert = await screen.findByTestId(
+      'recommended-sdk-upgrades-alert'
+    );
+
+    expect(
+      within(recommendedSdkUpgradesAlert).getByText(
+        'To activate sampling rules, it’s a requirement to update the following project SDK(s):'
+      )
+    ).toBeInTheDocument();
+
+    expect(
+      within(recommendedSdkUpgradesAlert).getByRole('link', {
+        name: projects[1].slug,
+      })
+    ).toHaveAttribute(
+      'href',
+      `/organizations/org-slug/projects/sentry/?project=${projects[1].id}`
+    );
+
+    // Open Modal
+    userEvent.click(
+      within(recommendedSdkUpgradesAlert).getByRole('button', {
+        name: 'Learn More',
+      })
+    );
+
+    expect(
+      await screen.findByRole('heading', {name: 'Important next steps'})
+    ).toBeInTheDocument();
+  });
+
+  it('open specific conditions modal when adding rule', async function () {
+    const {organization, project} = initializeOrg({
+      ...initializeOrg(),
+      organization: {
+        ...initializeOrg().organization,
+        features,
+      },
+      projects: [
+        TestStubs.Project({
+          dynamicSampling: {
+            rules: [
+              {
+                sampleRate: 1,
+                type: 'trace',
+                active: false,
+                condition: {
+                  op: 'and',
+                  inner: [],
+                },
+                id: 1,
+              },
+            ],
+          },
+        }),
+      ],
+    });
+
+    const mockRequests = renderMockRequests({
+      organizationSlug: organization.slug,
+      projectSlug: project.slug,
+    });
+
+    const memoryHistory = createMemoryHistory();
+
+    memoryHistory.push(
+      `/settings/${organization.slug}/projects/${project.slug}/dynamic-sampling/`
+    );
+
+    function DynamicSamplingPage() {
+      return <TestComponent organization={organization} project={project} withModal />;
+    }
+
+    function AlternativePage() {
+      return <div>alternative page</div>;
+    }
+
+    render(
+      <Router
+        history={memoryHistory}
+        render={props => {
+          return (
+            <RouteContext.Provider value={props}>
+              <RouterContext {...props} />
+            </RouteContext.Provider>
+          );
+        }}
+      >
+        <Route
+          path={`/settings/${organization.slug}/projects/${project.slug}/dynamic-sampling/`}
+        >
+          <IndexRoute component={DynamicSamplingPage} />
+          <Route path="rules/:rule/" component={DynamicSamplingPage} />
+        </Route>
+        <Route path="mock-path" component={AlternativePage} />
+      </Router>
+    );
+
+    // Store is reset on the first load
+    expect(ServerSideSamplingStore.getState().projectStats48h.data).toBe(undefined);
+    expect(ServerSideSamplingStore.getState().projectStats30d.data).toBe(undefined);
+    expect(ServerSideSamplingStore.getState().distribution.data).toBe(undefined);
+    expect(ServerSideSamplingStore.getState().sdkVersions.data).toBe(undefined);
+
+    // Store is updated with request responses on first load
+    await waitFor(() => {
+      expect(ServerSideSamplingStore.getState().sdkVersions.data).not.toBe(undefined);
+    });
+    expect(ServerSideSamplingStore.getState().projectStats48h.data).not.toBe(undefined);
+    expect(ServerSideSamplingStore.getState().projectStats30d.data).not.toBe(undefined);
+    expect(ServerSideSamplingStore.getState().distribution.data).not.toBe(undefined);
+
+    // Open Modal (new route)
+    userEvent.click(screen.getByLabelText('Add Rule'));
+
+    expect(await screen.findByRole('heading', {name: 'Add Rule'})).toBeInTheDocument();
+
+    // In a new route, if the store contains the required values, no further requests are sent
+    expect(mockRequests.statsV2).toHaveBeenCalledTimes(2);
+    expect(mockRequests.distribution).toHaveBeenCalledTimes(1);
+    expect(mockRequests.sdkVersions).toHaveBeenCalledTimes(1);
+
+    // Leave dynamic sampling's page
+    memoryHistory.push(`mock-path`);
+
+    // When leaving dynamic sampling's page the ServerSideSamplingStore is reset
+    expect(ServerSideSamplingStore.getState().projectStats48h.data).toBe(undefined);
+    expect(ServerSideSamplingStore.getState().projectStats30d.data).toBe(undefined);
+    expect(ServerSideSamplingStore.getState().distribution.data).toBe(undefined);
+    expect(ServerSideSamplingStore.getState().sdkVersions.data).toBe(undefined);
+  });
+
+  it('does not let user add without permissions', async function () {
+    const {organization, router, project} = initializeOrg({
+      ...initializeOrg(),
+      organization: {
+        ...initializeOrg().organization,
+        features,
+        access: [],
+      },
+      projects: [
+        TestStubs.Project({
+          dynamicSampling: {
+            rules: [
+              {
+                sampleRate: 1,
+                type: 'trace',
+                active: false,
+                condition: {
+                  op: 'and',
+                  inner: [],
+                },
+                id: 1,
+              },
+            ],
+          },
+        }),
+      ],
+    });
+
+    const mockRequests = renderMockRequests({
+      organizationSlug: organization.slug,
+      projectSlug: project.slug,
+    });
+
+    render(
+      <TestComponent organization={organization} project={project} router={router} />
+    );
+
+    expect(screen.getByRole('button', {name: 'Add Rule'})).toBeDisabled();
+    userEvent.hover(screen.getByText('Add Rule'));
+    expect(
+      await screen.findByText("You don't have permission to add a rule")
+    ).toBeInTheDocument();
+
+    expect(mockRequests.distribution).not.toHaveBeenCalled();
+    expect(mockRequests.sdkVersions).not.toHaveBeenCalled();
+  });
+
+  it('does not let the user activate a rule if sdk updates exists', async function () {
+    const {organization, router, project} = initializeOrg({
+      ...initializeOrg(),
+      organization: {
+        ...initializeOrg().organization,
+        features,
+      },
+      projects: [
+        TestStubs.Project({
+          dynamicSampling: {
+            rules: [TestStubs.DynamicSamplingConfig().uniformRule],
+          },
+        }),
+      ],
+    });
+
+    renderMockRequests({
+      organizationSlug: organization.slug,
+      projectSlug: project.slug,
+    });
+
+    render(
+      <TestComponent organization={organization} project={project} router={router} />
+    );
+
+    await screen.findByTestId('recommended-sdk-upgrades-alert');
+
+    expect(screen.getByRole('checkbox', {name: 'Activate Rule'})).toBeDisabled();
+
+    userEvent.hover(screen.getByLabelText('Activate Rule'));
+
+    expect(
+      await screen.findByText(
+        'To enable the rule, the recommended sdk version have to be updated'
+      )
+    ).toBeInTheDocument();
+  });
+
+  it('does not let the user activate an uniform rule if still processing', async function () {
+    const {organization, router, project} = initializeOrg({
+      ...initializeOrg(),
+      organization: {
+        ...initializeOrg().organization,
+        features,
+      },
+      projects: [
+        TestStubs.Project({
+          dynamicSampling: {
+            rules: [TestStubs.DynamicSamplingConfig().uniformRule],
+          },
+        }),
+      ],
+    });
+
+    renderMockRequests({
+      organizationSlug: organization.slug,
+      projectSlug: project.slug,
+      mockedSdkVersionsResponse: [],
+    });
+
+    render(
+      <TestComponent router={router} organization={organization} project={project} />
+    );
+
+    expect(await screen.findByRole('checkbox', {name: 'Activate Rule'})).toBeDisabled();
+
+    userEvent.hover(screen.getByLabelText('Activate Rule'));
+
+    expect(
+      await screen.findByText(
+        'We are processing sampling information for your project, so you cannot enable the rule yet. Please check again later'
+      )
+    ).toBeInTheDocument();
+  });
+
+  it('does not let user reorder uniform rule', async function () {
+    const {organization, router, project} = initializeOrg({
+      ...initializeOrg(),
+      organization: {
+        ...initializeOrg().organization,
+        features,
+      },
+      projects: [
+        TestStubs.Project({
+          dynamicSampling: {
+            rules: [
+              TestStubs.DynamicSamplingConfig().specificRule,
+              TestStubs.DynamicSamplingConfig().uniformRule,
+            ],
+          },
+        }),
+      ],
+    });
+
+    renderMockRequests({
+      organizationSlug: organization.slug,
+      projectSlug: project.slug,
+    });
+
+    render(
+      <TestComponent
+        organization={organization}
+        project={project}
+        router={router}
+        withModal
+      />
+    );
+
+    const samplingUniformRule = screen.getAllByTestId('sampling-rule')[1];
+
+    expect(
+      within(samplingUniformRule).getByRole('button', {name: 'Drag Rule'})
+    ).toHaveAttribute('aria-disabled', 'true');
+
+    userEvent.hover(within(samplingUniformRule).getByLabelText('Drag Rule'));
+
+    expect(
+      await screen.findByText('Uniform rules cannot be reordered')
+    ).toBeInTheDocument();
+  });
+});

+ 584 - 0
static/app/views/settings/project/dynamicSampling/dynamicSampling.tsx

@@ -0,0 +1,584 @@
+import {Fragment, useCallback, useEffect, useState} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import isEqual from 'lodash/isEqual';
+
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
+import {openModal} from 'sentry/actionCreators/modal';
+import {
+  fetchProjectStats,
+  fetchSamplingDistribution,
+  fetchSamplingSdkVersions,
+} from 'sentry/actionCreators/serverSideSampling';
+import GuideAnchor from 'sentry/components/assistant/guideAnchor';
+import Button from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import FeatureBadge from 'sentry/components/featureBadge';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {Panel, PanelFooter, PanelHeader} from 'sentry/components/panels';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {IconAdd} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import ProjectsStore from 'sentry/stores/projectsStore';
+import {ServerSideSamplingStore} from 'sentry/stores/serverSideSamplingStore';
+import space from 'sentry/styles/space';
+import {Project} from 'sentry/types';
+import {SamplingRule, SamplingRuleOperator} from 'sentry/types/sampling';
+import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
+import useApi from 'sentry/utils/useApi';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useParams} from 'sentry/utils/useParams';
+import usePrevious from 'sentry/utils/usePrevious';
+import {useRouteContext} from 'sentry/utils/useRouteContext';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import PermissionAlert from 'sentry/views/settings/organization/permissionAlert';
+
+import {SpecificConditionsModal} from './modals/specificConditionsModal';
+import {responsiveModal} from './modals/styles';
+import {useProjectStats} from './utils/useProjectStats';
+import {useRecommendedSdkUpgrades} from './utils/useRecommendedSdkUpgrades';
+import {DraggableRuleList, DraggableRuleListUpdateItemsProps} from './draggableRuleList';
+import {
+  ActiveColumn,
+  Column,
+  ConditionColumn,
+  GrabColumn,
+  OperatorColumn,
+  RateColumn,
+  Rule,
+} from './rule';
+import {SamplingBreakdown} from './samplingBreakdown';
+import {SamplingFeedback} from './samplingFeedback';
+import {SamplingFromOtherProject} from './samplingFromOtherProject';
+import {SamplingProjectIncompatibleAlert} from './samplingProjectIncompatibleAlert';
+import {SamplingPromo} from './samplingPromo';
+import {SamplingSDKClientRateChangeAlert} from './samplingSDKClientRateChangeAlert';
+import {SamplingSDKUpgradesAlert} from './samplingSDKUpgradesAlert';
+import {isUniformRule, SERVER_SIDE_SAMPLING_DOC_LINK} from './utils';
+
+type Props = {
+  project: Project;
+};
+
+export function DynamicSampling({project}: Props) {
+  const organization = useOrganization();
+  const api = useApi();
+
+  const hasAccess = organization.access.includes('project:write');
+  const canDemo = organization.features.includes('dynamic-sampling-demo');
+  const currentRules = project.dynamicSampling?.rules;
+
+  const previousRules = usePrevious(currentRules);
+  const navigate = useNavigate();
+  const params = useParams();
+  const routeContext = useRouteContext();
+  const router = routeContext.router;
+
+  const samplingProjectSettingsPath = `/settings/${organization.slug}/projects/${project.slug}/dynamic-sampling/`;
+
+  const [rules, setRules] = useState<SamplingRule[]>(currentRules ?? []);
+
+  useEffect(() => {
+    trackAdvancedAnalyticsEvent('sampling.settings.view', {
+      organization,
+      project_id: project.id,
+    });
+  }, [project.id, organization]);
+
+  useEffect(() => {
+    return () => {
+      if (!router.location.pathname.startsWith(samplingProjectSettingsPath)) {
+        ServerSideSamplingStore.reset();
+      }
+    };
+  }, [router.location.pathname, samplingProjectSettingsPath]);
+
+  useEffect(() => {
+    if (!isEqual(previousRules, currentRules)) {
+      setRules(currentRules ?? []);
+    }
+  }, [currentRules, previousRules]);
+
+  useEffect(() => {
+    if (!hasAccess) {
+      return;
+    }
+
+    async function fetchData() {
+      fetchProjectStats({
+        orgSlug: organization.slug,
+        api,
+        projId: project.id,
+      });
+
+      await fetchSamplingDistribution({
+        orgSlug: organization.slug,
+        projSlug: project.slug,
+        api,
+      });
+
+      await fetchSamplingSdkVersions({
+        orgSlug: organization.slug,
+        api,
+        projectID: project.id,
+      });
+    }
+
+    fetchData();
+  }, [api, organization.slug, project.slug, project.id, hasAccess]);
+
+  const handleReadDocs = useCallback(() => {
+    trackAdvancedAnalyticsEvent('sampling.settings.view_read_docs', {
+      organization,
+      project_id: project.id,
+    });
+  }, [organization, project.id]);
+
+  const {
+    recommendedSdkUpgrades,
+    isProjectIncompatible,
+    loading: loadingRecommendedSdkUpgrades,
+  } = useRecommendedSdkUpgrades({
+    organization,
+    projectId: project.id,
+  });
+
+  const handleOpenSpecificConditionsModal = useCallback(
+    (rule?: SamplingRule) => {
+      openModal(
+        modalProps => (
+          <SpecificConditionsModal
+            {...modalProps}
+            organization={organization}
+            project={project}
+            rule={rule}
+            rules={rules}
+          />
+        ),
+        {
+          modalCss: responsiveModal,
+          onClose: () => {
+            navigate(samplingProjectSettingsPath);
+          },
+        }
+      );
+    },
+    [navigate, organization, project, rules, samplingProjectSettingsPath]
+  );
+
+  useEffect(() => {
+    if (
+      router.location.pathname !== `${samplingProjectSettingsPath}rules/${params.rule}/`
+    ) {
+      return;
+    }
+
+    if (router.location.pathname === `${samplingProjectSettingsPath}rules/new/`) {
+      handleOpenSpecificConditionsModal();
+      return;
+    }
+
+    const rule = rules.find(r => String(r.id) === params.rule);
+
+    if (!rule) {
+      addErrorMessage(t('Unable to find sampling rule'));
+      return;
+    }
+
+    handleOpenSpecificConditionsModal(rule);
+  }, [
+    params.rule,
+    handleOpenSpecificConditionsModal,
+    rules,
+    router.location.pathname,
+    samplingProjectSettingsPath,
+  ]);
+
+  const {projectStats48h} = useProjectStats();
+
+  async function handleActivateToggle(rule: SamplingRule) {
+    if (isProjectIncompatible) {
+      addErrorMessage(t('Your project is currently incompatible with Dynamic Sampling.'));
+      return;
+    }
+
+    const newRules = rules.map(r => {
+      if (r.id === rule.id) {
+        return {
+          ...r,
+          id: 0,
+          active: !r.active,
+        };
+      }
+      return r;
+    });
+
+    addLoadingMessage();
+    try {
+      const result = await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/`,
+        {
+          method: 'PUT',
+          data: {dynamicSampling: {rules: newRules}},
+        }
+      );
+      ProjectsStore.onUpdateSuccess(result);
+      addSuccessMessage(t('Successfully updated the sampling rule'));
+    } catch (error) {
+      const message = t('Unable to update the sampling rule');
+      handleXhrErrorResponse(message)(error);
+      addErrorMessage(message);
+    }
+
+    if (isUniformRule(rule)) {
+      trackAdvancedAnalyticsEvent(
+        rule.active
+          ? 'sampling.settings.rule.uniform_deactivate'
+          : 'sampling.settings.rule.uniform_activate',
+        {
+          organization,
+          project_id: project.id,
+          sampling_rate: rule.sampleRate,
+        }
+      );
+    } else {
+      const analyticsConditions = rule.condition.inner.map(condition => condition.name);
+      const analyticsConditionsStringified = analyticsConditions.sort().join(', ');
+
+      trackAdvancedAnalyticsEvent(
+        rule.active
+          ? 'sampling.settings.rule.specific_deactivate'
+          : 'sampling.settings.rule.specific_activate',
+        {
+          organization,
+          project_id: project.id,
+          sampling_rate: rule.sampleRate,
+          conditions: analyticsConditions,
+          conditions_stringified: analyticsConditionsStringified,
+        }
+      );
+    }
+  }
+
+  function handleGetStarted() {
+    trackAdvancedAnalyticsEvent('sampling.settings.view_get_started', {
+      organization,
+      project_id: project.id,
+    });
+
+    navigate(`${samplingProjectSettingsPath}rules/uniform/`);
+  }
+
+  async function handleSortRules({
+    overIndex,
+    reorderedItems: ruleIds,
+  }: DraggableRuleListUpdateItemsProps) {
+    if (!rules[overIndex].condition.inner.length) {
+      addErrorMessage(t('Specific rules cannot be below uniform rules'));
+      return;
+    }
+
+    const sortedRules = ruleIds
+      .map(ruleId => rules.find(rule => String(rule.id) === ruleId))
+      .filter(rule => !!rule) as SamplingRule[];
+
+    setRules(sortedRules);
+
+    try {
+      const result = await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/`,
+        {
+          method: 'PUT',
+          data: {dynamicSampling: {rules: sortedRules}},
+        }
+      );
+      ProjectsStore.onUpdateSuccess(result);
+      addSuccessMessage(t('Successfully sorted sampling rules'));
+    } catch (error) {
+      setRules(previousRules ?? []);
+      const message = t('Unable to sort sampling rules');
+      handleXhrErrorResponse(message)(error);
+      addErrorMessage(message);
+    }
+  }
+
+  async function handleDeleteRule(rule: SamplingRule) {
+    const conditions = rule.condition.inner.map(({name}) => name);
+
+    trackAdvancedAnalyticsEvent('sampling.settings.rule.specific_delete', {
+      organization,
+      project_id: project.id,
+      sampling_rate: rule.sampleRate,
+      conditions,
+      conditions_stringified: conditions.sort().join(', '),
+    });
+
+    try {
+      const result = await api.requestPromise(
+        `/projects/${organization.slug}/${project.slug}/`,
+        {
+          method: 'PUT',
+          data: {dynamicSampling: {rules: rules.filter(({id}) => id !== rule.id)}},
+        }
+      );
+      ProjectsStore.onUpdateSuccess(result);
+      addSuccessMessage(t('Successfully deleted sampling rule'));
+    } catch (error) {
+      const message = t('Unable to delete sampling rule');
+      handleXhrErrorResponse(message)(error);
+      addErrorMessage(message);
+    }
+  }
+
+  // Rules without a condition (Else case) always have to be 'pinned' to the bottom of the list
+  // and cannot be sorted
+  const items = rules.map(rule => ({
+    ...rule,
+    id: String(rule.id),
+  }));
+
+  const uniformRule = rules.find(isUniformRule);
+
+  return (
+    <SentryDocumentTitle title={t('Dynamic Sampling')}>
+      <Fragment>
+        <SettingsPageHeader
+          title={
+            <Fragment>
+              {t('Dynamic Sampling')} <FeatureBadge type="beta" />
+            </Fragment>
+          }
+          action={<SamplingFeedback />}
+        />
+        <TextBlock>
+          {tct(
+            'Improve the accuracy of your [performanceMetrics: performance metrics] and [targetTransactions: target those transactions] which are most valuable for your organization. Server-side rules are applied immediately, with no need to re-deploy your app. To learn more about our beta program, [faqLink: visit our FAQ].',
+            {
+              performanceMetrics: (
+                <ExternalLink href="https://docs.sentry.io/product/performance/metrics/#metrics-and-sampling" />
+              ),
+              targetTransactions: <ExternalLink href={SERVER_SIDE_SAMPLING_DOC_LINK} />,
+
+              faqLink: (
+                <ExternalLink href="https://help.sentry.io/account/account-settings/dynamic-sampling/" />
+              ),
+              docsLink: <ExternalLink href={SERVER_SIDE_SAMPLING_DOC_LINK} />,
+            }
+          )}
+        </TextBlock>
+        <PermissionAlert
+          access={['project:write']}
+          message={t(
+            'These settings can only be edited by users with the organization owner, manager, or admin role.'
+          )}
+        />
+
+        <SamplingProjectIncompatibleAlert
+          organization={organization}
+          projectId={project.id}
+          isProjectIncompatible={isProjectIncompatible}
+        />
+
+        {!!rules.length && (
+          <SamplingSDKUpgradesAlert
+            organization={organization}
+            projectId={project.id}
+            recommendedSdkUpgrades={recommendedSdkUpgrades}
+            onReadDocs={handleReadDocs}
+          />
+        )}
+
+        {!!rules.length && !recommendedSdkUpgrades.length && (
+          <SamplingSDKClientRateChangeAlert
+            onReadDocs={handleReadDocs}
+            projectStats={projectStats48h.data}
+            organization={organization}
+            projectId={project.id}
+          />
+        )}
+
+        <SamplingFromOtherProject
+          orgSlug={organization.slug}
+          projectSlug={project.slug}
+        />
+
+        {hasAccess && <SamplingBreakdown orgSlug={organization.slug} />}
+        {!rules.length ? (
+          <SamplingPromo
+            onGetStarted={handleGetStarted}
+            onReadDocs={handleReadDocs}
+            hasAccess={hasAccess}
+          />
+        ) : (
+          <RulesPanel>
+            <RulesPanelHeader lightText>
+              <RulesPanelLayout>
+                <GrabColumn />
+                <OperatorColumn>{t('Operator')}</OperatorColumn>
+                <ConditionColumn>{t('Condition')}</ConditionColumn>
+                <RateColumn>{t('Rate')}</RateColumn>
+                <ActiveColumn>{t('Active')}</ActiveColumn>
+                <Column />
+              </RulesPanelLayout>
+            </RulesPanelHeader>
+            <DraggableRuleList
+              disabled={!hasAccess}
+              items={items}
+              onUpdateItems={handleSortRules}
+              wrapperStyle={({isDragging, isSorting, index}) => {
+                if (isDragging) {
+                  return {
+                    cursor: 'grabbing',
+                  };
+                }
+                if (isSorting) {
+                  return {};
+                }
+                return {
+                  transform: 'none',
+                  transformOrigin: '0',
+                  '--box-shadow': 'none',
+                  '--box-shadow-picked-up': 'none',
+                  overflow: 'visible',
+                  position: 'relative',
+                  zIndex: rules.length - index,
+                  cursor: 'default',
+                };
+              }}
+              renderItem={({value, listeners, attributes, dragging, sorting}) => {
+                const itemsRuleIndex = items.findIndex(item => item.id === value);
+
+                if (itemsRuleIndex === -1) {
+                  return null;
+                }
+
+                const itemsRule = items[itemsRuleIndex];
+
+                const currentRule = {
+                  active: itemsRule.active,
+                  condition: itemsRule.condition,
+                  sampleRate: itemsRule.sampleRate,
+                  type: itemsRule.type,
+                  id: Number(itemsRule.id),
+                };
+
+                return (
+                  <RulesPanelLayout isContent>
+                    <Rule
+                      operator={
+                        itemsRule.id === items[0].id
+                          ? SamplingRuleOperator.IF
+                          : isUniformRule(currentRule)
+                          ? SamplingRuleOperator.ELSE
+                          : SamplingRuleOperator.ELSE_IF
+                      }
+                      hideGrabButton={items.length === 1}
+                      rule={currentRule}
+                      onEditRule={() => {
+                        navigate(
+                          isUniformRule(currentRule)
+                            ? `${samplingProjectSettingsPath}rules/uniform/`
+                            : `${samplingProjectSettingsPath}rules/${currentRule.id}/`
+                        );
+                      }}
+                      canDemo={canDemo}
+                      onDeleteRule={() => handleDeleteRule(currentRule)}
+                      onActivate={() => handleActivateToggle(currentRule)}
+                      noPermission={!hasAccess}
+                      upgradeSdkForProjects={recommendedSdkUpgrades.map(
+                        recommendedSdkUpgrade => recommendedSdkUpgrade.project.slug
+                      )}
+                      listeners={listeners}
+                      grabAttributes={attributes}
+                      dragging={dragging}
+                      sorting={sorting}
+                      loadingRecommendedSdkUpgrades={loadingRecommendedSdkUpgrades}
+                    />
+                  </RulesPanelLayout>
+                );
+              }}
+            />
+            <RulesPanelFooter>
+              <ButtonBar gap={1}>
+                <Button
+                  href={SERVER_SIDE_SAMPLING_DOC_LINK}
+                  onClick={handleReadDocs}
+                  external
+                >
+                  {t('Read Docs')}
+                </Button>
+                <GuideAnchor
+                  target="add_conditional_rule"
+                  disabled={!uniformRule?.active || !hasAccess || rules.length !== 1}
+                >
+                  <AddRuleButton
+                    disabled={!hasAccess}
+                    title={
+                      !hasAccess
+                        ? t("You don't have permission to add a rule")
+                        : undefined
+                    }
+                    priority="primary"
+                    onClick={() => navigate(`${samplingProjectSettingsPath}rules/new/`)}
+                    icon={<IconAdd isCircled />}
+                  >
+                    {t('Add Rule')}
+                  </AddRuleButton>
+                </GuideAnchor>
+              </ButtonBar>
+            </RulesPanelFooter>
+          </RulesPanel>
+        )}
+      </Fragment>
+    </SentryDocumentTitle>
+  );
+}
+
+const RulesPanel = styled(Panel)``;
+
+const RulesPanelHeader = styled(PanelHeader)`
+  padding: ${space(0.5)} 0;
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const RulesPanelLayout = styled('div')<{isContent?: boolean}>`
+  width: 100%;
+  display: grid;
+  grid-template-columns: 1fr 0.5fr 74px;
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: 48px 97px 1fr 0.5fr 77px 74px;
+  }
+
+  ${p =>
+    p.isContent &&
+    css`
+      > * {
+        /* match the height of the ellipsis button */
+        line-height: 34px;
+        border-bottom: 1px solid ${p.theme.border};
+      }
+    `}
+`;
+
+const RulesPanelFooter = styled(PanelFooter)`
+  border-top: none;
+  padding: ${space(1.5)} ${space(2)};
+  grid-column: 1 / -1;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+`;
+
+const AddRuleButton = styled(Button)`
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    width: 100%;
+  }
+`;

+ 29 - 0
static/app/views/settings/project/dynamicSampling/index.tsx

@@ -0,0 +1,29 @@
+import Feature from 'sentry/components/acl/feature';
+import {Project} from 'sentry/types';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import ServerSideSampling from '../server-side-sampling';
+
+import {DynamicSampling} from './dynamicSampling';
+
+type Props = {
+  project: Project;
+};
+
+export default function DynamicSamplingContainer({project}: Props) {
+  const organization = useOrganization();
+
+  if (!organization.features.includes('dynamic-sampling-total-transaction-packaging')) {
+    return <ServerSideSampling project={project} />;
+  }
+
+  return (
+    <Feature
+      features={['server-side-sampling', 'server-side-sampling-ui']}
+      hookName="feature-disabled:dynamic-sampling-basic"
+      organization={organization}
+    >
+      <DynamicSampling project={project} />
+    </Feature>
+  );
+}

Some files were not shown because too many files changed in this diff