Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions static/app/types/organization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface Organization extends OrganizationSummary {
scrubIPAddresses: boolean;
sensitiveFields: string[];
storeCrashReports: number;
targetSampleRate: number;
teamRoleList: TeamRole[];
trustedRelays: Relay[];
desiredSampleRate?: number | null;
Expand Down
122 changes: 122 additions & 0 deletions static/app/views/settings/dynamicSampling/dynamicSampling.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {css} from '@emotion/react';
import styled from '@emotion/styled';

import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {Button} from 'sentry/components/button';
import Confirm from 'sentry/components/confirm';
import FieldGroup from 'sentry/components/forms/fieldGroup';
import Panel from 'sentry/components/panels/panel';
import PanelBody from 'sentry/components/panels/panelBody';
import PanelHeader from 'sentry/components/panels/panelHeader';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {useMutation} from 'sentry/utils/queryClient';
import useApi from 'sentry/utils/useApi';
import useOrganization from 'sentry/utils/useOrganization';
import {dynamicSamplingForm} from 'sentry/views/settings/dynamicSampling/dynamicSamplingForm';
import {TargetSampleRateField} from 'sentry/views/settings/dynamicSampling/targetSampleRateField';
import {useAccess} from 'sentry/views/settings/projectMetrics/access';

const {useFormState, FormProvider} = dynamicSamplingForm;

export function DynamicSampling() {
const api = useApi();
const organization = useOrganization();
const {hasAccess} = useAccess({access: ['org:write']});

const formState = useFormState({
targetSampleRate: ((organization.targetSampleRate ?? 1) * 100)?.toLocaleString(),
samplingMode: 'auto' as const,
});

const modeField = formState.fields.samplingMode;
const endpoint = `/organizations/${organization.slug}/`;

const {mutate: updateOrganization, isPending} = useMutation({
mutationFn: () => {
const {fields} = formState;
return api.requestPromise(endpoint, {
method: 'PUT',
data: {
targetSampleRate: Number(fields.targetSampleRate.value) / 100,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a thought: do we want to make sure this is of certain precision?

},
});
},
onSuccess: () => {
addSuccessMessage(t('Changes applied.'));
formState.save();
},
onError: () => {
addErrorMessage(t('Unable to save changes. Please try again.'));
},
});

const handleSubmit = () => {
updateOrganization();
};

const handleReset = () => {
formState.reset();
};

return (
<FormProvider formState={formState}>
<form onSubmit={event => event.preventDefault()}>
<Panel>
<PanelHeader>{t('Automatic Sampling')}</PanelHeader>
<PanelBody>
<FieldGroup
label={t('Sampling Mode')}
help={t('Changes the level of detail and configuring sample rates.')}
>
{t('Automatic Balancing')}
</FieldGroup>
{/* TODO(aknaus): move into separate component when we make it interactive */}
<FieldGroup
disabled
label={t('Switch Mode')}
help={t(
'Take control over the individual sample rates in your projects. This disables automatic adjustments.'
)}
>
<Confirm disabled>
<Button
title={t('This feature is not yet available.')}
css={css`
width: max-content;
`}
>
{modeField.value === 'auto'
? t('Switch to Manual')
: t('Switch to Auto')}
</Button>
</Confirm>
</FieldGroup>
{modeField.value === 'auto' ? <TargetSampleRateField /> : null}
</PanelBody>
</Panel>
<FormActions>
<Button disabled={!formState.hasChanged || isPending} onClick={handleReset}>
{t('Reset')}
</Button>
Comment on lines +99 to +101
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add a tooltip informing the users why the button is disabled? It is pretty self-explanatory to me but imo it would be nice to have.

<Button
priority="primary"
disabled={
!hasAccess || !formState.isValid || !formState.hasChanged || isPending
}
onClick={handleSubmit}
>
{t('Save changes')}
</Button>
</FormActions>
</form>
</FormProvider>
);
}

const FormActions = styled('div')`
display: grid;
grid-template-columns: repeat(2, max-content);
gap: ${space(1)};
justify-content: flex-end;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {t} from 'sentry/locale';
import {createForm} from 'sentry/views/settings/dynamicSampling/formContext';

type FormFields = {
samplingMode: 'auto' | 'manual';
targetSampleRate: string;
};

export const dynamicSamplingForm = createForm<FormFields>({
validators: {
targetSampleRate: (value: string) => {
if (value === '') {
return t('This field is required.');
}

const numericValue = Number(value);
if (isNaN(numericValue) ? t('Please enter a valid number.') : undefined) {
return t('Please enter a valid number.');
}

if (numericValue < 0 || numericValue > 100) {
return t('The sample rate must be between 0% and 100%');
}
return undefined;
},
},
});
147 changes: 147 additions & 0 deletions static/app/views/settings/dynamicSampling/formContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {createContext, useCallback, useContext, useState} from 'react';

interface FormState<FormFields extends Record<string, any>> {
/**
* State for each field in the form.
*/
fields: {
[K in keyof FormFields]: {
hasChanged: boolean;
initialValue: FormFields[K];
onChange: (value: FormFields[K]) => void;
value: FormFields[K];
error?: string;
};
};
/**
* Whether the form has changed from the initial values.
*/
hasChanged: boolean;
/**
* Whether the form is valid.
* A form is valid if all fields pass validation.
*/
isValid: boolean;
/**
* Resets the form state to the initial values.
*/
reset: () => void;
/**
* Saves the form state by setting the initial values to the current values.
*/
save: () => void;
}

export type FormValidators<FormFields extends Record<string, any>> = {
[K in keyof FormFields]?: (value: FormFields[K]) => string | undefined;
};

type InitialValues<FormFields extends Record<string, any>> = {
[K in keyof FormFields]: FormFields[K];
};

/**
* Creates a form state object with fields and validation for a given set of form fields.
*/
export const useFormState = <FormFields extends Record<string, any>>(config: {
initialValues: InitialValues<FormFields>;
validators?: FormValidators<FormFields>;
}): FormState<FormFields> => {
const [initialValues, setInitialValues] = useState(config.initialValues);
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState<{[K in keyof FormFields]?: string}>({});

const setValue = useCallback(
<K extends keyof FormFields>(name: K, value: FormFields[K]) => {
setValues(old => ({...old, [name]: value}));
},
[]
);

const setError = useCallback(
<K extends keyof FormFields>(name: K, error: string | undefined) => {
setErrors(old => ({...old, [name]: error}));
},
[]
);

/**
* Validates a field by running the field's validator function.
*/
const validateField = useCallback(
<K extends keyof FormFields>(name: K, value: FormFields[K]) => {
const validator = config.validators?.[name];
return validator?.(value);
},
[config.validators]
);

const handleFieldChange = <K extends keyof FormFields>(
name: K,
value: FormFields[K]
) => {
setValue(name, value);
setError(name, validateField(name, value));
};

return {
fields: Object.entries(values).reduce((acc, [name, value]) => {
acc[name as keyof FormFields] = {
value,
onChange: inputValue => handleFieldChange(name as keyof FormFields, inputValue),
error: errors[name as keyof FormFields],
hasChanged: value !== initialValues[name],
initialValue: initialValues[name],
};
return acc;
}, {} as any),
isValid: Object.values(errors).every(error => !error),
hasChanged: Object.entries(values).some(
([name, value]) => value !== initialValues[name]
),
save: () => {
setInitialValues(values);
},
reset: () => {
setValues(initialValues);
setErrors({});
},
};
};

/**
* Creates a form context and hooks for a form with a given set of fields to enable type-safe form handling.
*/
export const createForm = <FormFields extends Record<string, any>>({
validators,
}: {
validators?: FormValidators<FormFields>;
}) => {
const FormContext = createContext<FormState<FormFields> | undefined>(undefined);

function FormProvider({
children,
formState,
}: {
children: React.ReactNode;
formState: FormState<FormFields>;
}) {
return <FormContext.Provider value={formState}>{children}</FormContext.Provider>;
}

const useFormField = <K extends keyof FormFields>(name: K) => {
const formState = useContext(FormContext);
if (!formState) {
throw new Error('useFormField must be used within a FormProvider');
}

return formState.fields[name];
};

return {
useFormState: (initialValues: InitialValues<FormFields>) =>
useFormState<FormFields>({initialValues, validators}),
FormProvider,
useFormField,
};
};
2 changes: 2 additions & 0 deletions static/app/views/settings/dynamicSampling/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {t} from 'sentry/locale';
import {hasDynamicSamplingCustomFeature} from 'sentry/utils/dynamicSampling/features';
import useOrganization from 'sentry/utils/useOrganization';
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
import {DynamicSampling} from 'sentry/views/settings/dynamicSampling/dynamicSampling';

export default function DynamicSamplingSettings() {
const organization = useOrganization();
Expand All @@ -19,6 +20,7 @@ export default function DynamicSamplingSettings() {
<SentryDocumentTitle title={t('Dynamic Sampling')} orgSlug={organization.slug} />
<div>
<SettingsPageHeader title={t('Dynamic Sampling')} />
<DynamicSampling />
</div>
</Fragment>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import styled from '@emotion/styled';

import FieldGroup from 'sentry/components/forms/fieldGroup';
import {InputGroup} from 'sentry/components/inputGroup';
import {Tooltip} from 'sentry/components/tooltip';
import {t} from 'sentry/locale';
import {dynamicSamplingForm} from 'sentry/views/settings/dynamicSampling/dynamicSamplingForm';
import {useAccess} from 'sentry/views/settings/projectMetrics/access';

const {useFormField} = dynamicSamplingForm;

export function TargetSampleRateField({}) {
const field = useFormField('targetSampleRate');
const {hasAccess} = useAccess({access: ['org:write']});

return (
<FieldGroup
disabled={!hasAccess}
required
label={t('Target Sample Rate')}
help={t(
'Sentry will balance the sample rates of your projects automatically based on an overall target for your organization.'
)}
error={field.error}
>
<InputWrapper>
<Tooltip
disabled={hasAccess}
title={t('You do not have permission to change the sample rate.')}
>
<InputGroup>
<InputGroup.Input
width={100}
type="number"
disabled={!hasAccess}
value={field.value}
onChange={event => field.onChange(event.target.value)}
/>
<InputGroup.TrailingItems>
<TrailingPercent>%</TrailingPercent>
</InputGroup.TrailingItems>
</InputGroup>
</Tooltip>
{field.hasChanged ? (
<PreviousValue>{t('previous rate: %f%%', field.initialValue)}</PreviousValue>
) : null}
</InputWrapper>
</FieldGroup>
);
}

const PreviousValue = styled('span')`
font-size: ${p => p.theme.fontSizeExtraSmall};
color: ${p => p.theme.subText};
`;

const InputWrapper = styled('div')`
padding-top: 8px;
height: 58px;
display: flex;
flex-direction: column;
gap: 4px;
`;

const TrailingPercent = styled('strong')`
padding: 0 2px;
`;
1 change: 1 addition & 0 deletions tests/js/fixtures/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function OrganizationFixture( params: Partial<Organization> = {}): Organi
genAIConsent: false,
openMembership: false,
pendingAccessRequests: 0,
targetSampleRate: 1.0,
quota: {
accountLimit: null,
maxRate: null,
Expand Down
Loading