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
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ describe('CustomerOverview', () => {
const organization = OrganizationFixture({});
const subscription = SubscriptionFixture({
organization,
plan: 'am3_f',
});

subscription.planDetails = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {OrganizationFixture} from 'sentry-fixture/organization';

import {MetricHistoryFixture} from 'getsentry-test/fixtures/metricHistory';
import {PlanDetailsLookupFixture} from 'getsentry-test/fixtures/planDetailsLookup';
import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
import {
renderGlobalModal,
Expand Down Expand Up @@ -46,6 +47,7 @@ describe('UpdateRetentionSettingsModal', () => {
},
}),
},
planDetails: PlanDetailsLookupFixture('am3_f'),
});

openUpdateRetentionSettingsModal({
Expand All @@ -65,6 +67,43 @@ describe('UpdateRetentionSettingsModal', () => {
expect(screen.getByRole('button', {name: 'Update Settings'})).toBeInTheDocument();
});

it('prefills the form with existing AM2 retention values', async () => {
const subscription = SubscriptionFixture({
organization,
categories: {
transactions: MetricHistoryFixture({
retention: {
standard: 90,
downsampled: 30,
},
}),
logBytes: MetricHistoryFixture({
retention: {
standard: 45,
downsampled: 15,
},
}),
},
planDetails: PlanDetailsLookupFixture('am2_f'),
});

openUpdateRetentionSettingsModal({
subscription,
organization,
onSuccess,
});

await loadModal();

expect(getSpinbutton('Transactions Standard')).toHaveValue(90);
expect(getSpinbutton('Transactions Downsampled')).toHaveValue(30);
expect(getSpinbutton('Logs Standard')).toHaveValue(45);
expect(getSpinbutton('Logs Downsampled')).toHaveValue(15);

expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Update Settings'})).toBeInTheDocument();
});

it('handles null retention values', async () => {
const subscription = SubscriptionFixture({
organization,
Expand All @@ -82,6 +121,7 @@ describe('UpdateRetentionSettingsModal', () => {
},
}),
},
planDetails: PlanDetailsLookupFixture('am3_f'),
});

openUpdateRetentionSettingsModal({
Expand Down Expand Up @@ -115,6 +155,7 @@ describe('UpdateRetentionSettingsModal', () => {
},
}),
},
planDetails: PlanDetailsLookupFixture('am3_f'),
});

const updateMock = MockApiClient.addMockResponse({
Expand Down Expand Up @@ -169,6 +210,78 @@ describe('UpdateRetentionSettingsModal', () => {
expect(onSuccess).toHaveBeenCalled();
});

it('calls api with correct data when updating all AM2 fields', async () => {
const subscription = SubscriptionFixture({
organization,
categories: {
transactions: MetricHistoryFixture({
retention: {
standard: 90,
downsampled: 30,
},
}),
logBytes: MetricHistoryFixture({
retention: {
standard: 30,
downsampled: 7,
},
}),
},
planDetails: PlanDetailsLookupFixture('am2_f'),
});

const updateMock = MockApiClient.addMockResponse({
url: `/_admin/${organization.slug}/retention-settings/`,
method: 'POST',
body: {},
});

openUpdateRetentionSettingsModal({
subscription,
organization,
onSuccess,
});

await loadModal();

await userEvent.clear(getSpinbutton('Transactions Standard'));
await userEvent.type(getSpinbutton('Transactions Standard'), '120');

await userEvent.clear(getSpinbutton('Transactions Downsampled'));
await userEvent.type(getSpinbutton('Transactions Downsampled'), '60');

await userEvent.clear(getSpinbutton('Logs Standard'));
await userEvent.type(getSpinbutton('Logs Standard'), '60');

await userEvent.clear(getSpinbutton('Logs Downsampled'));
await userEvent.type(getSpinbutton('Logs Downsampled'), '14');

await userEvent.click(screen.getByRole('button', {name: 'Update Settings'}));

await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith(
`/_admin/${organization.slug}/retention-settings/`,
expect.objectContaining({
method: 'POST',
data: {
retentions: {
transactions: {
standard: 120,
downsampled: 60,
},
logBytes: {
standard: 60,
downsampled: 14,
},
},
},
})
);
});

expect(onSuccess).toHaveBeenCalled();
});

it('calls api with null downsampled values when fields are empty', async () => {
const subscription = SubscriptionFixture({
organization,
Expand All @@ -186,6 +299,7 @@ describe('UpdateRetentionSettingsModal', () => {
},
}),
},
planDetails: PlanDetailsLookupFixture('am3_f'),
});

const updateMock = MockApiClient.addMockResponse({
Expand Down Expand Up @@ -255,6 +369,7 @@ describe('UpdateRetentionSettingsModal', () => {
},
}),
},
planDetails: PlanDetailsLookupFixture('am3_f'),
});

const updateMock = MockApiClient.addMockResponse({
Expand Down Expand Up @@ -326,6 +441,7 @@ describe('UpdateRetentionSettingsModal', () => {
},
}),
},
planDetails: PlanDetailsLookupFixture('am3_f'),
});

const updateMock = MockApiClient.addMockResponse({
Expand Down Expand Up @@ -389,6 +505,7 @@ describe('UpdateRetentionSettingsModal', () => {
},
}),
},
planDetails: PlanDetailsLookupFixture('am3_f'),
});

const updateMock = MockApiClient.addMockResponse({
Expand Down
140 changes: 96 additions & 44 deletions static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal';
import {openModal} from 'sentry/actionCreators/modal';
import NumberField from 'sentry/components/forms/fields/numberField';
import Form from 'sentry/components/forms/form';
import {DataCategory} from 'sentry/types/core';
import type {Organization} from 'sentry/types/organization';
import useApi from 'sentry/utils/useApi';

import type {Subscription} from 'getsentry/types';
import {hasCategoryFeature} from 'getsentry/utils/dataCategory';

type Props = {
onSuccess: () => void;
Expand All @@ -28,32 +30,55 @@ function UpdateRetentionSettingsModal({
}: ModalProps) {
const api = useApi();

const [spansStandard, setSpansStandard] = useState<number | null | string>(
subscription.categories.spans?.retention?.standard ?? null
);
const [spansDownsampled, setSpansDownsampled] = useState<number | null | string>(
subscription.categories.spans?.retention?.downsampled ?? null
);
const [logBytesStandard, setLogBytesStandard] = useState<number | null | string>(
subscription.categories.logBytes?.retention?.standard ?? null
);
const [logBytesDownsampled, setLogBytesDownsampled] = useState<number | null | string>(
subscription.categories.logBytes?.retention?.downsampled ?? null
);

const [transactionsStandard, setTransactionsStandard] = useState<
number | null | string
>(subscription.categories.transactions?.retention?.standard ?? null);
const [transactionsDownsampled, setTransactionsDownsampled] = useState<
number | null | string
>(subscription.categories.transactions?.retention?.downsampled ?? null);

const [spansStandard, setSpansStandard] = useState<number | null | string>(
subscription.categories.spans?.retention?.standard ?? null
);
const [spansDownsampled, setSpansDownsampled] = useState<number | null | string>(
subscription.categories.spans?.retention?.downsampled ?? null
);

const onSubmit = () => {
const data = {
retentions: {
spans: {
standard: Number(spansStandard),
downsampled: spansDownsampled === '' ? null : Number(spansDownsampled),
},
logBytes: {
standard: Number(logBytesStandard),
downsampled: logBytesDownsampled === '' ? null : Number(logBytesDownsampled),
},
},
};
const retentions: Partial<
Record<DataCategory, {downsampled: number | null; standard: number}>
> = {};

if (hasCategoryFeature(DataCategory.LOG_BYTE, subscription, organization)) {
retentions.logBytes = {
standard: Number(logBytesStandard),
downsampled: logBytesDownsampled === '' ? null : Number(logBytesDownsampled),
};
}

if (hasCategoryFeature(DataCategory.TRANSACTIONS, subscription, organization)) {
retentions.transactions = {
standard: Number(transactionsStandard),
downsampled:
transactionsDownsampled === '' ? null : Number(transactionsDownsampled),
};
}

if (hasCategoryFeature(DataCategory.SPANS, subscription, organization)) {
retentions.spans = {
standard: Number(spansStandard),
downsampled: spansDownsampled === '' ? null : Number(spansDownsampled),
};
}

const data = {retentions};

api.request(`/_admin/${organization.slug}/retention-settings/`, {
method: 'POST',
Expand Down Expand Up @@ -85,32 +110,59 @@ function UpdateRetentionSettingsModal({
</div>
<br />
<Form onSubmit={onSubmit} submitLabel="Update Settings" onCancel={closeModal}>
<NumberField
name="spansStandard"
label="Spans Standard"
defaultValue={spansStandard}
onChange={setSpansStandard}
required
/>
<NumberField
name="spansDownsampled"
label="Spans Downsampled"
defaultValue={spansDownsampled}
onChange={setSpansDownsampled}
/>
<NumberField
name="logBytesStandard"
label="Logs Standard"
defaultValue={logBytesStandard}
onChange={setLogBytesStandard}
required
/>
<NumberField
name="logBytesDownsampled"
label="Logs Downsampled"
defaultValue={logBytesDownsampled}
onChange={setLogBytesDownsampled}
/>
{hasCategoryFeature(DataCategory.LOG_BYTE, subscription, organization) && (
<Fragment>
<NumberField
name="logBytesStandard"
label="Logs Standard"
defaultValue={logBytesStandard}
onChange={setLogBytesStandard}
required
/>
<NumberField
name="logBytesDownsampled"
label="Logs Downsampled"
defaultValue={logBytesDownsampled}
onChange={setLogBytesDownsampled}
/>
</Fragment>
)}

{hasCategoryFeature(DataCategory.TRANSACTIONS, subscription, organization) && (
<Fragment>
<NumberField
name="transactionsStandard"
label="Transactions Standard"
defaultValue={transactionsStandard}
onChange={setTransactionsStandard}
required
/>
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: Inconsistent Required Props Breaks Validation Consistency

The transactionsStandard NumberField is missing the required prop, unlike the spansStandard and logBytesStandard fields. This inconsistency allows an empty value to be submitted, which converts to 0 and may cause backend validation issues or unintended retention behavior, as standard retention values are generally expected.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The field is has the required tag now

<NumberField
name="transactionsDownsampled"
label="Transactions Downsampled"
defaultValue={transactionsDownsampled}
onChange={setTransactionsDownsampled}
/>
</Fragment>
)}

{hasCategoryFeature(DataCategory.SPANS, subscription, organization) && (
<Fragment>
<NumberField
name="spansStandard"
label="Spans Standard"
defaultValue={spansStandard}
onChange={setSpansStandard}
required
/>
<NumberField
name="spansDownsampled"
label="Spans Downsampled"
defaultValue={spansDownsampled}
onChange={setSpansDownsampled}
/>
</Fragment>
)}
</Form>
</Body>
</Fragment>
Expand Down
1 change: 1 addition & 0 deletions static/gsApp/utils/billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,7 @@ export function getReservedBudgetCategoryForAddOn(addOnCategory: AddOnCategory)
export const RETENTION_SETTINGS_CATEGORIES = new Set([
DataCategory.SPANS,
DataCategory.LOG_BYTE,
DataCategory.TRANSACTIONS,
]);

export function getCredits({
Expand Down
Loading