From 79cc4ff4f9e351eb17c56646c8aa6164e4052a65 Mon Sep 17 00:00:00 2001 From: Valery Brobbey Date: Mon, 3 Nov 2025 12:00:59 -0800 Subject: [PATCH 1/2] feat(billing): add transactions to retentions UI --- .../updateRetentionSettingsModal.spec.tsx | 117 +++++++++++++++ .../updateRetentionSettingsModal.tsx | 139 ++++++++++++------ static/gsApp/utils/billing.tsx | 1 + 3 files changed, 213 insertions(+), 44 deletions(-) diff --git a/static/gsAdmin/components/customers/updateRetentionSettingsModal.spec.tsx b/static/gsAdmin/components/customers/updateRetentionSettingsModal.spec.tsx index 52dc13f2958133..762e3d1815d5d5 100644 --- a/static/gsAdmin/components/customers/updateRetentionSettingsModal.spec.tsx +++ b/static/gsAdmin/components/customers/updateRetentionSettingsModal.spec.tsx @@ -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, @@ -46,6 +47,7 @@ describe('UpdateRetentionSettingsModal', () => { }, }), }, + planDetails: PlanDetailsLookupFixture('am3_f'), }); openUpdateRetentionSettingsModal({ @@ -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, @@ -82,6 +121,7 @@ describe('UpdateRetentionSettingsModal', () => { }, }), }, + planDetails: PlanDetailsLookupFixture('am3_f'), }); openUpdateRetentionSettingsModal({ @@ -115,6 +155,7 @@ describe('UpdateRetentionSettingsModal', () => { }, }), }, + planDetails: PlanDetailsLookupFixture('am3_f'), }); const updateMock = MockApiClient.addMockResponse({ @@ -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, @@ -186,6 +299,7 @@ describe('UpdateRetentionSettingsModal', () => { }, }), }, + planDetails: PlanDetailsLookupFixture('am3_f'), }); const updateMock = MockApiClient.addMockResponse({ @@ -255,6 +369,7 @@ describe('UpdateRetentionSettingsModal', () => { }, }), }, + planDetails: PlanDetailsLookupFixture('am3_f'), }); const updateMock = MockApiClient.addMockResponse({ @@ -326,6 +441,7 @@ describe('UpdateRetentionSettingsModal', () => { }, }), }, + planDetails: PlanDetailsLookupFixture('am3_f'), }); const updateMock = MockApiClient.addMockResponse({ @@ -389,6 +505,7 @@ describe('UpdateRetentionSettingsModal', () => { }, }), }, + planDetails: PlanDetailsLookupFixture('am3_f'), }); const updateMock = MockApiClient.addMockResponse({ diff --git a/static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx b/static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx index 8bbc74322081c3..1fe314025cf273 100644 --- a/static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx +++ b/static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx @@ -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; @@ -28,12 +30,6 @@ function UpdateRetentionSettingsModal({ }: ModalProps) { const api = useApi(); - const [spansStandard, setSpansStandard] = useState( - subscription.categories.spans?.retention?.standard ?? null - ); - const [spansDownsampled, setSpansDownsampled] = useState( - subscription.categories.spans?.retention?.downsampled ?? null - ); const [logBytesStandard, setLogBytesStandard] = useState( subscription.categories.logBytes?.retention?.standard ?? null ); @@ -41,19 +37,48 @@ function UpdateRetentionSettingsModal({ 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( + subscription.categories.spans?.retention?.standard ?? null + ); + const [spansDownsampled, setSpansDownsampled] = useState( + 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 + > = {}; + + 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', @@ -85,32 +110,58 @@ function UpdateRetentionSettingsModal({
- - - - + {hasCategoryFeature(DataCategory.LOG_BYTE, subscription, organization) && ( + + + + + )} + + {hasCategoryFeature(DataCategory.TRANSACTIONS, subscription, organization) && ( + + + + + )} + + {hasCategoryFeature(DataCategory.SPANS, subscription, organization) && ( + + + + + )} diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 7bb2b29d225c99..3a6bdc58c4d97e 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -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({ From 3423b1ae53e6990416c61938bfd48dff3dac9931 Mon Sep 17 00:00:00 2001 From: Valery Brobbey Date: Mon, 3 Nov 2025 14:59:31 -0800 Subject: [PATCH 2/2] make txn field required, fix test by adding plan --- static/gsAdmin/components/customers/customerOverview.spec.tsx | 1 + .../components/customers/updateRetentionSettingsModal.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/static/gsAdmin/components/customers/customerOverview.spec.tsx b/static/gsAdmin/components/customers/customerOverview.spec.tsx index 7fbccdcafa4792..71ab58156d6796 100644 --- a/static/gsAdmin/components/customers/customerOverview.spec.tsx +++ b/static/gsAdmin/components/customers/customerOverview.spec.tsx @@ -642,6 +642,7 @@ describe('CustomerOverview', () => { const organization = OrganizationFixture({}); const subscription = SubscriptionFixture({ organization, + plan: 'am3_f', }); subscription.planDetails = { diff --git a/static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx b/static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx index 1fe314025cf273..980d24eaaa973e 100644 --- a/static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx +++ b/static/gsAdmin/components/customers/updateRetentionSettingsModal.tsx @@ -135,6 +135,7 @@ function UpdateRetentionSettingsModal({ label="Transactions Standard" defaultValue={transactionsStandard} onChange={setTransactionsStandard} + required />