123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581 |
- import {Fragment, 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 {
- 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 {Panel, PanelFooter, PanelHeader} from 'sentry/components/panels';
- import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
- import {IconAdd} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import ProjectStore from 'sentry/stores/projectsStore';
- import space from 'sentry/styles/space';
- import {Project} from 'sentry/types';
- import {
- SamplingConditionOperator,
- SamplingRule,
- SamplingRuleOperator,
- SamplingRuleType,
- UniformModalsSubmit,
- } from 'sentry/types/sampling';
- import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
- import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
- import useApi from 'sentry/utils/useApi';
- import useOrganization from 'sentry/utils/useOrganization';
- import usePrevious from 'sentry/utils/usePrevious';
- 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 {UniformRateModal} from './modals/uniformRateModal';
- import useProjectStats from './utils/useProjectStats';
- import {useRecommendedSdkUpgrades} from './utils/useRecommendedSdkUpgrades';
- import {DraggableRuleList, DraggableRuleListUpdateItemsProps} from './draggableRuleList';
- import {Promo} from './promo';
- import {
- ActiveColumn,
- Column,
- ConditionColumn,
- GrabColumn,
- OperatorColumn,
- RateColumn,
- Rule,
- } from './rule';
- import {SamplingSDKAlert} from './samplingSDKAlert';
- import {isUniformRule, SERVER_SIDE_SAMPLING_DOC_LINK} from './utils';
- type Props = {
- project: Project;
- };
- export function ServerSideSampling({project}: Props) {
- const organization = useOrganization();
- const api = useApi();
- const hasAccess = organization.access.includes('project:write');
- const currentRules = project.dynamicSampling?.rules;
- const previousRules = usePrevious(currentRules);
- const [rules, setRules] = useState<SamplingRule[]>(currentRules ?? []);
- useEffect(() => {
- trackAdvancedAnalyticsEvent('sampling.settings.view', {
- organization: organization.slug,
- project_id: project.id,
- });
- }, [project.id, organization.slug]);
- useEffect(() => {
- if (!isEqual(previousRules, currentRules)) {
- setRules(currentRules ?? []);
- }
- }, [currentRules, previousRules]);
- useEffect(() => {
- if (!hasAccess) {
- return;
- }
- async function fetchRecommendedSdkUpgrades() {
- await fetchSamplingDistribution({
- orgSlug: organization.slug,
- projSlug: project.slug,
- api,
- });
- await fetchSamplingSdkVersions({
- orgSlug: organization.slug,
- api,
- });
- }
- fetchRecommendedSdkUpgrades();
- }, [api, organization.slug, project.slug, hasAccess]);
- const {projectStats} = useProjectStats({
- orgSlug: organization.slug,
- projectId: project?.id,
- interval: '1h',
- statsPeriod: '48h',
- });
- const {recommendedSdkUpgrades} = useRecommendedSdkUpgrades({
- orgSlug: organization.slug,
- });
- async function handleActivateToggle(rule: SamplingRule) {
- 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}},
- }
- );
- ProjectStore.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: organization.slug,
- 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: organization.slug,
- project_id: project.id,
- sampling_rate: rule.sampleRate,
- conditions: analyticsConditions,
- conditions_stringified: analyticsConditionsStringified,
- }
- );
- }
- }
- function handleGetStarted() {
- trackAdvancedAnalyticsEvent('sampling.settings.view_get_started', {
- organization: organization.slug,
- project_id: project.id,
- });
- openModal(
- modalProps => (
- <UniformRateModal
- {...modalProps}
- organization={organization}
- project={project}
- projectStats={projectStats}
- rules={rules}
- onSubmit={saveUniformRule}
- onReadDocs={handleReadDocs}
- />
- ),
- {
- modalCss: responsiveModal,
- }
- );
- }
- 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}},
- }
- );
- ProjectStore.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);
- }
- }
- function handleAddRule() {
- openModal(modalProps => (
- <SpecificConditionsModal
- {...modalProps}
- organization={organization}
- project={project}
- rules={rules}
- />
- ));
- }
- function handleEditRule(rule: SamplingRule) {
- if (isUniformRule(rule)) {
- openModal(
- modalProps => (
- <UniformRateModal
- {...modalProps}
- organization={organization}
- project={project}
- projectStats={projectStats}
- uniformRule={rule}
- rules={rules}
- onSubmit={saveUniformRule}
- onReadDocs={handleReadDocs}
- />
- ),
- {
- modalCss: responsiveModal,
- }
- );
- return;
- }
- openModal(modalProps => (
- <SpecificConditionsModal
- {...modalProps}
- organization={organization}
- project={project}
- rule={rule}
- rules={rules}
- />
- ));
- }
- 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)}},
- }
- );
- ProjectStore.onUpdateSuccess(result);
- addSuccessMessage(t('Successfully deleted sampling rule'));
- } catch (error) {
- const message = t('Unable to delete sampling rule');
- handleXhrErrorResponse(message)(error);
- addErrorMessage(message);
- }
- }
- function handleReadDocs() {
- trackAdvancedAnalyticsEvent('sampling.settings.view_read_docs', {
- organization: organization.slug,
- project_id: project.id,
- });
- }
- async function saveUniformRule({
- sampleRate,
- uniformRateModalOrigin,
- onError,
- onSuccess,
- rule,
- }: Parameters<UniformModalsSubmit>[0]) {
- const newRule: SamplingRule = {
- // All new/updated rules must have id equal to 0
- id: 0,
- active: rule ? rule.active : false,
- type: SamplingRuleType.TRACE,
- condition: {
- op: SamplingConditionOperator.AND,
- inner: [],
- },
- sampleRate,
- };
- trackAdvancedAnalyticsEvent(
- uniformRateModalOrigin
- ? 'sampling.settings.modal.uniform.rate_done'
- : 'sampling.settings.modal.recommended.next.steps_done',
- {
- organization: organization.slug,
- project_id: project.id,
- }
- );
- trackAdvancedAnalyticsEvent(
- rule
- ? 'sampling.settings.rule.uniform_update'
- : 'sampling.settings.rule.uniform_create',
- {
- organization: organization.slug,
- project_id: project.id,
- sampling_rate: newRule.sampleRate,
- old_sampling_rate: rule ? rule.sampleRate : null,
- }
- );
- const newRules = rule
- ? rules.map(existingRule => (existingRule.id === rule.id ? newRule : existingRule))
- : [...rules, newRule];
- try {
- const response = await api.requestPromise(
- `/projects/${organization.slug}/${project.slug}/`,
- {method: 'PUT', data: {dynamicSampling: {rules: newRules}}}
- );
- ProjectStore.onUpdateSuccess(response);
- addSuccessMessage(
- rule
- ? t('Successfully edited sampling rule')
- : t('Successfully added sampling rule')
- );
- onSuccess?.();
- } catch (error) {
- addErrorMessage(
- typeof error === 'string'
- ? error
- : error.message || t('Failed to save sampling rule')
- );
- onError?.();
- }
- }
- // 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('Server-side Sampling')}>
- <Fragment>
- <SettingsPageHeader title={t('Server-side Sampling')} />
- <TextBlock>
- {t(
- 'Server-side sampling provides an additional dial for dropping transactions. This comes in handy when your server-side sampling rules target the transactions you want to keep, but you need more of those transactions being sent by the SDK.'
- )}
- </TextBlock>
- <PermissionAlert
- access={['project:write']}
- message={t(
- 'These settings can only be edited by users with the organization owner, manager, or admin role.'
- )}
- />
- {!!rules.length && (
- <SamplingSDKAlert
- organization={organization}
- projectId={project.id}
- rules={rules}
- recommendedSdkUpgrades={recommendedSdkUpgrades}
- onReadDocs={handleReadDocs}
- />
- )}
- <RulesPanel>
- <RulesPanelHeader lightText>
- <RulesPanelLayout>
- <GrabColumn />
- <OperatorColumn>{t('Operator')}</OperatorColumn>
- <ConditionColumn>{t('Condition')}</ConditionColumn>
- <RateColumn>{t('Rate')}</RateColumn>
- <ActiveColumn>{t('Active')}</ActiveColumn>
- <Column />
- </RulesPanelLayout>
- </RulesPanelHeader>
- {!rules.length && (
- <Promo
- onGetStarted={handleGetStarted}
- onReadDocs={handleReadDocs}
- hasAccess={hasAccess}
- />
- )}
- {!!rules.length && (
- <Fragment>
- <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={() => handleEditRule(currentRule)}
- onDeleteRule={() => handleDeleteRule(currentRule)}
- onActivate={() => handleActivateToggle(currentRule)}
- noPermission={!hasAccess}
- upgradeSdkForProjects={recommendedSdkUpgrades.map(
- recommendedSdkUpgrade => recommendedSdkUpgrade.project.slug
- )}
- listeners={listeners}
- grabAttributes={attributes}
- dragging={dragging}
- sorting={sorting}
- />
- </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={handleAddRule}
- icon={<IconAdd isCircled />}
- >
- {t('Add Rule')}
- </AddRuleButton>
- </GuideAnchor>
- </ButtonBar>
- </RulesPanelFooter>
- </Fragment>
- )}
- </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%;
- }
- `;
|