From 2e6c74372ba918825e06d08a31e731544a0f8fc2 Mon Sep 17 00:00:00 2001 From: Hector Dearman Date: Thu, 2 Apr 2026 16:56:39 +0100 Subject: [PATCH 01/80] ref(preprod): Remove snapshots from retention endpoint (#112096) Remove the hardcoded `snapshots` field from the preprod retention endpoint response. We don't use snapshots from launchpad so there's no need to expose retention for them from this endpoint. Agent transcript: https://claudescope.sentry.dev/share/G4IesPU-uYwagotQ6onn2g5xtr8VOAkKk4do_ZRFGJg --- .../preprod/api/endpoints/organization_preprod_retention.py | 1 - .../api/endpoints/test_organization_preprod_retention.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/organization_preprod_retention.py b/src/sentry/preprod/api/endpoints/organization_preprod_retention.py index b4a1c385cc63b8..9acff6b9d0fc93 100644 --- a/src/sentry/preprod/api/endpoints/organization_preprod_retention.py +++ b/src/sentry/preprod/api/endpoints/organization_preprod_retention.py @@ -72,6 +72,5 @@ def get(self, request: Request, organization: Organization) -> Response: { "size": size_retention, "buildDistribution": build_distribution_retention, - "snapshots": 30, # Hardcoded for now, check with Objectstore before increasing } ) diff --git a/tests/sentry/preprod/api/endpoints/test_organization_preprod_retention.py b/tests/sentry/preprod/api/endpoints/test_organization_preprod_retention.py index ccb5ab95375688..96d53cd71a5718 100644 --- a/tests/sentry/preprod/api/endpoints/test_organization_preprod_retention.py +++ b/tests/sentry/preprod/api/endpoints/test_organization_preprod_retention.py @@ -25,7 +25,6 @@ def test_get_default_retention(self) -> None: assert response.status_code == 200 assert response.data["size"] == 90 assert response.data["buildDistribution"] == 90 - assert response.data["snapshots"] == 30 @patch("sentry.quotas.backend.get_event_retention") def test_get_custom_retention(self, mock_get_retention) -> None: @@ -39,7 +38,6 @@ def test_get_custom_retention(self, mock_get_retention) -> None: assert response.status_code == 200 assert response.data["size"] == 30 assert response.data["buildDistribution"] == 60 - assert response.data["snapshots"] == 30 def test_get_requires_authentication(self) -> None: client = APIClient() From 58d978dce27f8a14d93eab2fe7e23704b17b0150 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Thu, 2 Apr 2026 12:11:54 -0400 Subject: [PATCH 02/80] feat(github): Add frontend implementation for GitHub integration pipeline (#111996) Adds the GitHub-specific pipeline step components (OAuth login and organization selection) and registers the GitHub integration pipeline definition in the frontend registry. Refs VDY-38 Typical install flow https://github.com/user-attachments/assets/e05fe79c-aa7a-4d12-847b-e7b4aab96c8e Installing for a org with an already integrated github app https://github.com/user-attachments/assets/250e0982-1ba3-440d-af92-9e59cee76ecc --- .../app/components/pipeline/index.stories.tsx | 8 +- .../pipeline/pipelineIntegrationGitHub.tsx | 283 ++++++++++++++++++ static/app/components/pipeline/registry.tsx | 6 +- static/app/types/hooks.tsx | 11 + .../hooks/scmGithubMultiOrgInstall.spec.tsx | 156 ++++++++++ .../gsApp/hooks/scmGithubMultiOrgInstall.tsx | 149 +++++++++ static/gsApp/registerHooks.tsx | 2 + 7 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 static/app/components/pipeline/pipelineIntegrationGitHub.tsx create mode 100644 static/gsApp/hooks/scmGithubMultiOrgInstall.spec.tsx create mode 100644 static/gsApp/hooks/scmGithubMultiOrgInstall.tsx diff --git a/static/app/components/pipeline/index.stories.tsx b/static/app/components/pipeline/index.stories.tsx index 750ca75021f12a..fe4ce271b316b0 100644 --- a/static/app/components/pipeline/index.stories.tsx +++ b/static/app/components/pipeline/index.stories.tsx @@ -10,7 +10,11 @@ import * as Storybook from 'sentry/stories'; import {openPipelineModal} from './modal'; import {PIPELINE_REGISTRY} from './registry'; -import type {ProvidersByType, RegisteredPipelineType} from './registry'; +import type { + CompletionDataFor, + ProvidersByType, + RegisteredPipelineType, +} from './registry'; import {usePipeline} from './usePipeline'; const pipelineMenuItems = PIPELINE_REGISTRY.map(p => ({ @@ -123,7 +127,7 @@ function PipelineRunner({ } function PipelineModalDemo() { - const [result, setResult] = useState | null>(null); + const [result, setResult] = useState | null>(null); return ( diff --git a/static/app/components/pipeline/pipelineIntegrationGitHub.tsx b/static/app/components/pipeline/pipelineIntegrationGitHub.tsx new file mode 100644 index 00000000000000..94d58ee6ea1e29 --- /dev/null +++ b/static/app/components/pipeline/pipelineIntegrationGitHub.tsx @@ -0,0 +1,283 @@ +import {useCallback} from 'react'; + +import {Avatar} from '@sentry/scraps/avatar'; +import {Button} from '@sentry/scraps/button'; +import {Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {HookOrDefault} from 'sentry/components/hookOrDefault'; +import {t, tn} from 'sentry/locale'; +import type {ScmGithubMultiOrgInstallProps} from 'sentry/types/hooks'; +import type {IntegrationWithConfig} from 'sentry/types/integrations'; + +import type {OAuthCallbackData} from './shared/oauthLoginStep'; +import {OAuthLoginStep} from './shared/oauthLoginStep'; +import {useRedirectPopupStep} from './shared/useRedirectPopupStep'; +import type {PipelineDefinition, PipelineStepProps} from './types'; +import {pipelineComplete} from './types'; + +export interface InstallationInfo { + avatarUrl: string; + githubAccount: string; + installationId: string; + count?: number | null; +} + +interface OrgSelectionStepData { + installAppUrl?: string; + installationInfo?: InstallationInfo[]; +} + +interface OrgSelectionAdvanceData { + chosenInstallationId?: string; + installationId?: string; +} + +function GitHubOAuthLoginStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps< + {oauthUrl?: string}, + {code: string; state: string; installationId?: string} +>) { + const handleOAuthCallback = useCallback( + (data: OAuthCallbackData) => { + advance({ + code: data.code, + state: data.state, + installationId: data.rest.installation_id, + }); + }, + [advance] + ); + + return ( + + ); +} + +export const NEW_INSTALL_KEY = '_new_install'; + +export function buildInstallationMenuItems( + installations: InstallationInfo[], + options?: {newInstallDisabled?: boolean} +) { + return [ + ...installations.map(inst => ({ + key: inst.installationId, + leadingItems: ( + + ), + label: `github.com/${inst.githubAccount}`, + details: inst.count + ? tn( + 'Connected to %s other Sentry organization', + 'Connected to %s other Sentry organizations', + inst.count + ) + : undefined, + })), + { + key: NEW_INSTALL_KEY, + label: t('Install on a new GitHub organization'), + disabled: options?.newInstallDisabled, + }, + ]; +} + +function DefaultGitHubMultiOrgInstall({ + installations, + onSelectInstallation, + onNewInstall, + isDisabled, + newInstallDisabled, + popupBlockedNotice, +}: ScmGithubMultiOrgInstallProps) { + const menuItems = buildInstallationMenuItems(installations, {newInstallDisabled}); + + return ( + + {popupBlockedNotice} + { + if (key === NEW_INSTALL_KEY) { + onNewInstall(); + } else { + onSelectInstallation(key as string); + } + }} + /> + + ); +} + +const GitHubMultiOrgInstall = HookOrDefault({ + hookName: 'component:scm-github-multi-org-install', + defaultComponent: DefaultGitHubMultiOrgInstall, +}); + +function OrgSelectionStep({ + stepData, + advance, + isAdvancing, +}: PipelineStepProps) { + const installations = stepData.installationInfo ?? []; + + const handleInstallCallback = useCallback( + (data: Record) => { + advance({ + installationId: data.installation_id as string, + }); + }, + [advance] + ); + + const {openPopup, isWaitingForCallback, popupStatus} = useRedirectPopupStep({ + redirectUrl: stepData.installAppUrl, + onCallback: handleInstallCallback, + }); + + // TODO(epurkhiser): Once we remove the legacy django views the function that + // generates this sentinel "Integrate with a new github organization" can be + // changed to not add this and we can drop this value here also + const existingInstallations = installations.filter( + inst => inst.installationId !== '-1' + ); + + if (existingInstallations.length === 0) { + return ( + : undefined + } + installDisabled={!stepData.installAppUrl} + onInstall={openPopup} + /> + ); + } + + return ( + + + {t( + "Select the GitHub organization you'd like to connect to Sentry, or install the Sentry GitHub App on a GitHub organization that does not already have the app installed." + )} + + + advance({chosenInstallationId: installationId}) + } + onNewInstall={openPopup} + isDisabled={isAdvancing} + newInstallDisabled={!stepData.installAppUrl} + popupBlockedNotice={ + popupStatus === 'failed-to-open' ? : undefined + } + /> + + ); +} + +function FreshInstallSteps({ + isAdvancing, + isWaitingForCallback, + popupBlockedNotice, + installDisabled, + onInstall, +}: { + installDisabled: boolean; + isAdvancing: boolean; + isWaitingForCallback: boolean; + onInstall: () => void; + popupBlockedNotice?: React.ReactNode; +}) { + if (isAdvancing) { + return ( + + + {t( + 'Complete the installation in the popup window. Once finished, this page will update automatically.' + )} + + + + ); + } + + if (isWaitingForCallback) { + return ( + + + {t( + 'Complete the installation in the popup window. Once finished, this page will update automatically.' + )} + + + + ); + } + + return ( + + + {t('Install the Sentry GitHub App on a GitHub organization to get started.')} + + {popupBlockedNotice} + + + ); +} + +function PopupBlockedNotice() { + return ( + + {t( + 'The installation popup was blocked by your browser. Please ensure popups are allowed and try again.' + )} + + ); +} + +export const githubIntegrationPipeline = { + type: 'integration', + provider: 'github', + actionTitle: t('Installing GitHub Integration'), + getCompletionData: pipelineComplete, + steps: [ + { + stepId: 'oauth_login', + shortDescription: t('Authorizing via GitHub OAuth flow'), + component: GitHubOAuthLoginStep, + }, + { + stepId: 'org_selection', + shortDescription: t('Installing GitHub Application'), + component: OrgSelectionStep, + }, + ], +} as const satisfies PipelineDefinition; diff --git a/static/app/components/pipeline/registry.tsx b/static/app/components/pipeline/registry.tsx index 6f70507831d97e..c88fad5a40162d 100644 --- a/static/app/components/pipeline/registry.tsx +++ b/static/app/components/pipeline/registry.tsx @@ -1,9 +1,13 @@ import {dummyIntegrationPipeline} from './pipelineDummyProvider'; +import {githubIntegrationPipeline} from './pipelineIntegrationGitHub'; /** * All registered pipeline definitions. */ -export const PIPELINE_REGISTRY = [dummyIntegrationPipeline] as const; +export const PIPELINE_REGISTRY = [ + dummyIntegrationPipeline, + githubIntegrationPipeline, +] as const; type AllPipelines = (typeof PIPELINE_REGISTRY)[number]; diff --git a/static/app/types/hooks.tsx b/static/app/types/hooks.tsx index 3f75c04634cc1b..83faa02bf2875f 100644 --- a/static/app/types/hooks.tsx +++ b/static/app/types/hooks.tsx @@ -3,6 +3,7 @@ import type {ButtonProps} from '@sentry/scraps/button'; import type {ChildrenRenderFn} from 'sentry/components/acl/feature'; import type {Guide} from 'sentry/components/assistant/types'; import type {ProductSelectionProps} from 'sentry/components/onboarding/productSelection'; +import type {InstallationInfo} from 'sentry/components/pipeline/pipelineIntegrationGitHub'; import type {DateRange} from 'sentry/components/timeRangeSelector/dateRange'; import type {SelectorItems} from 'sentry/components/timeRangeSelector/selectorItems'; import type {SentryRouteObject} from 'sentry/router/types'; @@ -136,6 +137,15 @@ type FirstPartyIntegrationAlertProps = { wrapWithContainer?: boolean; }; +export type ScmGithubMultiOrgInstallProps = { + installations: InstallationInfo[]; + onNewInstall: () => void; + onSelectInstallation: (installationId: string) => void; + isDisabled?: boolean; + newInstallDisabled?: boolean; + popupBlockedNotice?: React.ReactNode; +}; + type FirstPartyIntegrationAdditionalCTAProps = { integrations: Integration[]; }; @@ -223,6 +233,7 @@ type ComponentHooks = { 'component:replay-onboarding-alert': () => React.ComponentType; 'component:replay-onboarding-cta': () => React.ComponentType; 'component:replay-settings-alert': () => React.ComponentType | null; + 'component:scm-github-multi-org-install': () => React.ComponentType; 'component:seer-beta-closing-alert': () => React.ComponentType; 'component:superuser-access-category': React.ComponentType; 'component:superuser-warning': React.ComponentType; diff --git a/static/gsApp/hooks/scmGithubMultiOrgInstall.spec.tsx b/static/gsApp/hooks/scmGithubMultiOrgInstall.spec.tsx new file mode 100644 index 00000000000000..3de2265eb81697 --- /dev/null +++ b/static/gsApp/hooks/scmGithubMultiOrgInstall.spec.tsx @@ -0,0 +1,156 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {BillingConfigFixture} from 'getsentry-test/fixtures/billingConfig'; +import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {ScmGithubMultiOrgInstall} from 'getsentry/hooks/scmGithubMultiOrgInstall'; +import {SubscriptionStore} from 'getsentry/stores/subscriptionStore'; +import {PlanTier} from 'getsentry/types'; + +function makeInstallations( + overrides?: Array[0]>> +) { + const defaults = [ + {installationId: '100', githubAccount: 'my-org', count: 0}, + {installationId: '200', githubAccount: 'other-org', count: 2}, + ]; + return (overrides ?? defaults).map(makeInstallation); +} + +function makeInstallation( + partial: Partial<{ + avatarUrl: string; + count: number; + githubAccount: string; + installationId: string; + }> = {} +) { + return { + installationId: '100', + githubAccount: 'my-org', + avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4', + count: 0, + ...partial, + }; +} + +describe('ScmGithubMultiOrgInstall', () => { + const onSelectInstallation = jest.fn(); + const onNewInstall = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + MockApiClient.clearMockResponses(); + }); + + function renderComponent({ + hasFeature = false, + installations = makeInstallations(), + withSubscription = true, + }: { + hasFeature?: boolean; + installations?: ReturnType; + withSubscription?: boolean; + } = {}) { + const organization = OrganizationFixture({ + features: hasFeature ? ['integrations-scm-multi-org'] : [], + }); + + if (withSubscription) { + const subscription = SubscriptionFixture({organization}); + SubscriptionStore.set(organization.slug, subscription); + + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/billing-config/`, + query: {tier: 'am2'}, + body: BillingConfigFixture(PlanTier.AM2), + }); + } + + return render( + , + {organization} + ); + } + + it('renders dropdown without alert when org has scm-multi-org feature', () => { + renderComponent({hasFeature: true}); + + expect( + screen.getByRole('button', {name: 'Select GitHub organization'}) + ).toBeInTheDocument(); + expect( + screen.queryByText(/already connected to other Sentry organizations/) + ).not.toBeInTheDocument(); + }); + + it('renders dropdown without alert when no installations have count > 0', () => { + renderComponent({ + hasFeature: false, + installations: [ + makeInstallation({installationId: '100', githubAccount: 'my-org', count: 0}), + makeInstallation({ + installationId: '200', + githubAccount: 'other-org', + count: 0, + }), + ], + }); + + expect( + screen.getByRole('button', {name: 'Select GitHub organization'}) + ).toBeInTheDocument(); + expect( + screen.queryByText(/already connected to other Sentry organizations/) + ).not.toBeInTheDocument(); + }); + + it('shows upgrade alert when org lacks feature and has multi-org installations', () => { + renderComponent({hasFeature: false}); + + expect( + screen.getByText(/already connected to other Sentry organizations/) + ).toBeInTheDocument(); + expect(screen.getByText('Upgrade')).toBeInTheDocument(); + }); + + it('upgrade link points to billing page in new tab', () => { + renderComponent({hasFeature: false}); + + const link = screen.getByText('Upgrade').closest('a'); + expect(link).toHaveAttribute( + 'href', + expect.stringContaining('/billing/overview/?referrer=upgrade-github-multi-org') + ); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('calls onSelectInstallation when clicking a non-multi-org item', async () => { + renderComponent({hasFeature: true}); + + await userEvent.click( + screen.getByRole('button', {name: 'Select GitHub organization'}) + ); + await userEvent.click(await screen.findByText('github.com/my-org')); + + expect(onSelectInstallation).toHaveBeenCalledWith('100'); + }); + + it('calls onNewInstall when clicking new install option', async () => { + renderComponent({hasFeature: true}); + + await userEvent.click( + screen.getByRole('button', {name: 'Select GitHub organization'}) + ); + await userEvent.click( + await screen.findByText('Install on a new GitHub organization') + ); + + expect(onNewInstall).toHaveBeenCalled(); + }); +}); diff --git a/static/gsApp/hooks/scmGithubMultiOrgInstall.tsx b/static/gsApp/hooks/scmGithubMultiOrgInstall.tsx new file mode 100644 index 00000000000000..df1ccf42b74592 --- /dev/null +++ b/static/gsApp/hooks/scmGithubMultiOrgInstall.tsx @@ -0,0 +1,149 @@ +import {Alert} from '@sentry/scraps/alert'; +import {LinkButton} from '@sentry/scraps/button'; +import {Stack} from '@sentry/scraps/layout'; + +import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import { + buildInstallationMenuItems, + NEW_INSTALL_KEY, +} from 'sentry/components/pipeline/pipelineIntegrationGitHub'; +import {IconLightning} from 'sentry/icons'; +import {t, tct} from 'sentry/locale'; +import type {ScmGithubMultiOrgInstallProps} from 'sentry/types/hooks'; +import type {Organization} from 'sentry/types/organization'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {useBillingConfig} from 'getsentry/hooks/useBillingConfig'; +import {useSubscription} from 'getsentry/hooks/useSubscription'; +import type {BillingConfig, Subscription} from 'getsentry/types'; +import {displayPlanName} from 'getsentry/utils/billing'; + +export function ScmGithubMultiOrgInstall({ + installations, + onSelectInstallation, + onNewInstall, + isDisabled, + newInstallDisabled, + popupBlockedNotice, +}: ScmGithubMultiOrgInstallProps) { + const organization = useOrganization(); + const subscription = useSubscription(); + + const hasSCMMultiOrg = organization.features.includes('integrations-scm-multi-org'); + const hasMultiOrgInstallations = installations.some(inst => (inst.count ?? 0) > 0); + const needsUpgrade = !hasSCMMultiOrg && hasMultiOrgInstallations; + + const menuItems = buildInstallationMenuItems(installations, {newInstallDisabled}).map( + item => { + if (item.key === NEW_INSTALL_KEY) { + return item; + } + const inst = installations.find(i => i.installationId === item.key); + const isMultiOrg = (inst?.count ?? 0) > 0; + return {...item, disabled: needsUpgrade && isMultiOrg}; + } + ); + + return ( + + {needsUpgrade && ( + } + href={`/settings/${organization.slug}/billing/overview/?referrer=upgrade-github-multi-org`} + external + analyticsEventKey="github.multi_org.upsell" + analyticsEventName="Github Multi-Org Upsell Clicked" + > + {t('Upgrade')} + + } + > + + + )} + {popupBlockedNotice} + { + if (key === NEW_INSTALL_KEY) { + onNewInstall(); + } else { + onSelectInstallation(key as string); + } + }} + /> + + ); +} + +function UpgradeMessage({subscription}: {subscription: Subscription | null}) { + const organization = useOrganization(); + + if (!subscription) { + return ( + + {t( + 'Some GitHub organizations are already connected to other Sentry organizations. An upgraded plan is required to share GitHub installations across multiple Sentry organizations.' + )} + + ); + } + + return ( + + ); +} + +function UpgradeMessageWithBilling({ + organization, + subscription, +}: { + organization: Organization; + subscription: Subscription; +}) { + const {data: billingConfig} = useBillingConfig({organization, subscription}); + const planName = getRequiredPlanName(billingConfig); + + if (planName) { + return ( + + {tct( + 'Some GitHub organizations are already connected to other Sentry organizations. A [planName] plan or above is required to share GitHub installations across multiple Sentry organizations.', + {planName: {planName}} + )} + + ); + } + + return ( + + {t( + 'Some GitHub organizations are already connected to other Sentry organizations. An upgraded plan is required to share GitHub installations across multiple Sentry organizations.' + )} + + ); +} + +function getRequiredPlanName(billingConfig: BillingConfig | undefined): string | null { + if (!billingConfig) { + return null; + } + + const plan = billingConfig.planList + .filter(p => p.userSelectable) + .sort((a, b) => a.price - b.price) + .find(p => p.features.includes('integrations-scm-multi-org')); + + if (!plan) { + return null; + } + + return displayPlanName(plan); +} diff --git a/static/gsApp/registerHooks.tsx b/static/gsApp/registerHooks.tsx index d28046b05e5ee2..f04aa10726fdf6 100644 --- a/static/gsApp/registerHooks.tsx +++ b/static/gsApp/registerHooks.tsx @@ -62,6 +62,7 @@ import {getOrgRoles} from 'getsentry/hooks/organizationRoles'; import OrgStatsBanner from 'getsentry/hooks/orgStatsBanner'; import {OrgStatsProfilingBanner} from 'getsentry/hooks/orgStatsProfilingBanner'; import {rootRoutes} from 'getsentry/hooks/rootRoutes'; +import {ScmGithubMultiOrgInstall} from 'getsentry/hooks/scmGithubMultiOrgInstall'; import {seerSettingsRoutes} from 'getsentry/hooks/seerSettingsRoutes'; import {ComponentWrapper as EnhancedOrganizationStats} from 'getsentry/hooks/spendVisibility/enhancedIndex'; import {SpikeProtectionProjectSettings} from 'getsentry/hooks/spendVisibility/spikeProtectionProjectSettings'; @@ -233,6 +234,7 @@ const GETSENTRY_HOOKS: Partial = { 'component:first-party-integration-alert': () => FirstPartyIntegrationAlertHook, 'component:first-party-integration-additional-cta': () => FirstPartyIntegrationAdditionalCTA, + 'component:scm-github-multi-org-install': () => ScmGithubMultiOrgInstall, 'component:replay-onboarding-alert': () => ReplayOnboardingAlert, 'component:replay-onboarding-cta': () => ReplayOnboardingCTA, 'component:replay-settings-alert': () => ReplaySettingsAlert, From c76ab6eff9722dd8434771ff35be86335f09c324 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 2 Apr 2026 18:12:05 +0200 Subject: [PATCH 03/80] chore(org-tokens): Update scope help text for organization tokens (#112049) Follow-up to https://github.com/getsentry/sentry/pull/109783 --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- .../views/settings/organizationAuthTokens/newAuthToken.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/app/views/settings/organizationAuthTokens/newAuthToken.tsx b/static/app/views/settings/organizationAuthTokens/newAuthToken.tsx index 2c4202fad59b0a..64af028cdc9004 100644 --- a/static/app/views/settings/organizationAuthTokens/newAuthToken.tsx +++ b/static/app/views/settings/organizationAuthTokens/newAuthToken.tsx @@ -114,7 +114,9 @@ function AuthTokenCreateForm({ >
org:ci
- {t('Source Map Upload, Release Creation')} + + {t('Source Map Upload, Release Creation, Code Mappings')} +
From 9da8f497993843331d423c45573de6516d46f8cf Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 2 Apr 2026 12:17:05 -0400 Subject: [PATCH 04/80] ref(lint): update boundaries config (#112106) Updates `plugin/boundaries` config for v6 following the official [migration guide](https://www.jsboundaries.dev/docs/releases/migration-guides/v5-to-v6/) --- eslint.config.ts | 128 ++++++++++-------- .../components/core/avatar/avatar.spec.tsx | 2 +- .../avatar/imageAvatar/imageAvatar.spec.tsx | 2 +- .../avatar/letterAvatar/letterAvatar.spec.tsx | 2 +- .../core/avatar/sentryAppAvatar.spec.tsx | 2 +- .../core/avatar/teamAvatar.spec.tsx | 2 +- .../components/core/avatar/useAvatar.spec.tsx | 2 +- .../core/hotkey/useHotkeys.spec.tsx | 2 +- .../components/core/layout/styles.spec.tsx | 2 +- .../background-elevation-colors.png | Bin .../chonky-borders-perspective.png | Bin .../floating-elements-shadows.png | Bin .../grouped-interactive-elements.png | Bin .../issue-details-page-example.png | Bin .../monitor-config-page-example.png | Bin .../object-model-diagram.png | Bin .../layering-and-elevation.mdx | 8 +- .../core/textarea/textarea.spec.tsx | 2 +- 18 files changed, 85 insertions(+), 69 deletions(-) rename static/app/components/core/principles/layering-and-elevation/{ => __stories__}/background-elevation-colors.png (100%) rename static/app/components/core/principles/layering-and-elevation/{ => __stories__}/chonky-borders-perspective.png (100%) rename static/app/components/core/principles/layering-and-elevation/{ => __stories__}/floating-elements-shadows.png (100%) rename static/app/components/core/principles/layering-and-elevation/{ => __stories__}/grouped-interactive-elements.png (100%) rename static/app/components/core/principles/layering-and-elevation/{ => __stories__}/issue-details-page-example.png (100%) rename static/app/components/core/principles/layering-and-elevation/{ => __stories__}/monitor-config-page-example.png (100%) rename static/app/components/core/principles/layering-and-elevation/{ => __stories__}/object-model-diagram.png (100%) diff --git a/eslint.config.ts b/eslint.config.ts index 55e81a0cff8237..1e428637b610eb 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1262,95 +1262,111 @@ export default typescript.config([ rules: [ // --- figma code connect --- { - from: ['figma-code-connect'], - allow: ['core*'], + from: [{type: 'figma-code-connect'}], + allow: [{to: {type: 'core*'}}], }, { - from: ['sentry*'], - allow: ['core*', 'sentry*'], + from: [{type: 'sentry*'}], + allow: [{to: {type: 'core*'}}, {to: {type: 'sentry*'}}], }, { - from: ['getsentry*'], - allow: ['core*', 'getsentry*', 'sentry*'], + from: [{type: 'getsentry*'}], + allow: [ + {to: {type: 'core*'}}, + {to: {type: 'getsentry*'}}, + {to: {type: 'sentry*'}}, + ], }, { - from: ['gsAdmin*'], - disallow: ['sentry-locale'], - allow: ['core*', 'gsAdmin*', 'sentry*', 'getsentry*'], + from: [{type: 'gsAdmin*'}], + disallow: [{to: {type: 'sentry-locale'}}], + allow: [ + {to: {type: 'core*'}}, + {to: {type: 'gsAdmin*'}}, + {to: {type: 'sentry*'}}, + {to: {type: 'getsentry*'}}, + ], }, { - from: ['test-sentry'], - allow: ['test-sentry', 'test', 'core*', 'sentry*'], + from: [{type: 'test-sentry'}], + allow: [ + {to: {type: 'test-sentry'}}, + {to: {type: 'test'}}, + {to: {type: 'core*'}}, + {to: {type: 'sentry*'}}, + ], }, { // todo does test-gesentry need test-sentry? - from: ['test-getsentry'], + from: [{type: 'test-getsentry'}], allow: [ - 'test-getsentry', - 'test-sentry', - 'test', - 'core*', - 'getsentry*', - 'sentry*', + {to: {type: 'test-getsentry'}}, + {to: {type: 'test-sentry'}}, + {to: {type: 'test'}}, + {to: {type: 'core*'}}, + {to: {type: 'getsentry*'}}, + {to: {type: 'sentry*'}}, ], }, { - from: ['test-gsAdmin'], + from: [{type: 'test-gsAdmin'}], allow: [ - 'test-gsAdmin', - 'test-getsentry', - 'test-sentry', - 'test', - 'core*', - 'gsAdmin*', - 'sentry*', - 'getsentry*', + {to: {type: 'test-gsAdmin'}}, + {to: {type: 'test-getsentry'}}, + {to: {type: 'test-sentry'}}, + {to: {type: 'test'}}, + {to: {type: 'core*'}}, + {to: {type: 'gsAdmin*'}}, + {to: {type: 'sentry*'}}, + {to: {type: 'getsentry*'}}, ], }, { - from: ['test'], - allow: ['test', 'test-sentry', 'sentry*'], + from: [{type: 'test'}], + allow: [ + {to: {type: 'test'}}, + {to: {type: 'test-sentry'}}, + {to: {type: 'sentry*'}}, + ], }, { - from: ['configs'], - allow: ['configs', 'build-utils'], + from: [{type: 'configs'}], + allow: [{to: {type: 'configs'}}, {to: {type: 'build-utils'}}], }, // --- stories --- { - from: ['story-files', 'story-book'], - allow: ['core*', 'sentry*', 'story-book'], + from: [{type: 'story-files'}, {type: 'story-book'}], + allow: [ + {to: {type: 'core*'}}, + {to: {type: 'sentry*'}}, + {to: {type: 'story-book'}}, + ], }, // --- debug tools (e.g. notifications) --- { - from: ['debug-tools'], - allow: ['core*', 'sentry*', 'debug-tools'], + from: [{type: 'debug-tools'}], + allow: [ + {to: {type: 'core*'}}, + {to: {type: 'sentry*'}}, + {to: {type: 'debug-tools'}}, + ], }, // --- core --- // todo: sentry* shouldn't be allowed { - from: ['core'], - allow: ['core*', 'sentry*'], + from: [{type: 'core'}], + allow: [{to: {type: 'core*'}}, {to: {type: 'sentry*'}}], }, - ], - }, - ], - 'boundaries/entry-point': [ - 'error', - { - default: 'disallow', - rules: [ + // --- core entry points (enforce isolation) --- { - target: ['core'], - allow: [ - '*.{ts,tsx}', // core/renderToString.tsx at the core root etc. - '*/index.{ts,tsx}', // core/form/index.tsx, core/alert/index.tsx etc. - '**/*.png', // needed for story-files - '**/__stories__/*.{ts,tsx}', // story demo helpers imported by .mdx files - ], - }, - { - target: ['!core'], - allow: '**/*', + to: { + type: 'core', + internalPath: + '!(*.{ts,tsx}|*/index.{ts,tsx}|**/*.png|**/__stories__/*.{ts,tsx})', + }, + disallow: { + from: {type: '*'}, + }, }, ], }, diff --git a/static/app/components/core/avatar/avatar.spec.tsx b/static/app/components/core/avatar/avatar.spec.tsx index 4fe7d0775438e2..f0bf6bdbd3c054 100644 --- a/static/app/components/core/avatar/avatar.spec.tsx +++ b/static/app/components/core/avatar/avatar.spec.tsx @@ -1,6 +1,6 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {Avatar} from './avatar'; describe('Avatar', () => { diff --git a/static/app/components/core/avatar/imageAvatar/imageAvatar.spec.tsx b/static/app/components/core/avatar/imageAvatar/imageAvatar.spec.tsx index 5060d228f6634c..58bafde49bdfee 100644 --- a/static/app/components/core/avatar/imageAvatar/imageAvatar.spec.tsx +++ b/static/app/components/core/avatar/imageAvatar/imageAvatar.spec.tsx @@ -2,7 +2,7 @@ import type {Tagged} from 'type-fest'; import {render, screen} from 'sentry-test/reactTestingLibrary'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {ImageAvatar} from './imageAvatar'; describe('ImageAvatar', () => { diff --git a/static/app/components/core/avatar/letterAvatar/letterAvatar.spec.tsx b/static/app/components/core/avatar/letterAvatar/letterAvatar.spec.tsx index 2256a28952120c..77eee3f727a37a 100644 --- a/static/app/components/core/avatar/letterAvatar/letterAvatar.spec.tsx +++ b/static/app/components/core/avatar/letterAvatar/letterAvatar.spec.tsx @@ -2,7 +2,7 @@ import type {Tagged} from 'type-fest'; import {render, screen} from 'sentry-test/reactTestingLibrary'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {LetterAvatar, type LetterAvatarProps} from './letterAvatar'; function makeConfiguration( diff --git a/static/app/components/core/avatar/sentryAppAvatar.spec.tsx b/static/app/components/core/avatar/sentryAppAvatar.spec.tsx index 584ac363d4651e..ddba915638a343 100644 --- a/static/app/components/core/avatar/sentryAppAvatar.spec.tsx +++ b/static/app/components/core/avatar/sentryAppAvatar.spec.tsx @@ -2,7 +2,7 @@ import {SentryAppFixture} from 'sentry-fixture/sentryApp'; import {render, screen} from 'sentry-test/reactTestingLibrary'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {SentryAppAvatar} from './sentryAppAvatar'; describe('SentryAppAvatar', () => { diff --git a/static/app/components/core/avatar/teamAvatar.spec.tsx b/static/app/components/core/avatar/teamAvatar.spec.tsx index 40a68321b812ce..989f0555cce3de 100644 --- a/static/app/components/core/avatar/teamAvatar.spec.tsx +++ b/static/app/components/core/avatar/teamAvatar.spec.tsx @@ -2,7 +2,7 @@ import {TeamFixture} from 'sentry-fixture/team'; import {render, screen} from 'sentry-test/reactTestingLibrary'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {TeamAvatar} from './teamAvatar'; describe('TeamAvatar', () => { diff --git a/static/app/components/core/avatar/useAvatar.spec.tsx b/static/app/components/core/avatar/useAvatar.spec.tsx index c0b3c92e26ef48..2cfb82b861a5de 100644 --- a/static/app/components/core/avatar/useAvatar.spec.tsx +++ b/static/app/components/core/avatar/useAvatar.spec.tsx @@ -1,6 +1,6 @@ import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {Avatar} from './avatar'; /** diff --git a/static/app/components/core/hotkey/useHotkeys.spec.tsx b/static/app/components/core/hotkey/useHotkeys.spec.tsx index 6227000b4c0a95..d8e877b3d8866d 100644 --- a/static/app/components/core/hotkey/useHotkeys.spec.tsx +++ b/static/app/components/core/hotkey/useHotkeys.spec.tsx @@ -2,7 +2,7 @@ import {renderHook} from 'sentry-test/reactTestingLibrary'; import {useHotkeys} from '@sentry/scraps/hotkey'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {getKeyCode} from './keyMappings'; describe('useHotkeys', () => { diff --git a/static/app/components/core/layout/styles.spec.tsx b/static/app/components/core/layout/styles.spec.tsx index 32da475a1d309a..1edc21b7b9ae2e 100644 --- a/static/app/components/core/layout/styles.spec.tsx +++ b/static/app/components/core/layout/styles.spec.tsx @@ -5,7 +5,7 @@ import {act, renderHook} from 'sentry-test/reactTestingLibrary'; import type {BreakpointSize} from 'sentry/utils/theme'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {rc, useActiveBreakpoint, useResponsivePropValue, type Responsive} from './styles'; const theme = ThemeFixture(); diff --git a/static/app/components/core/principles/layering-and-elevation/background-elevation-colors.png b/static/app/components/core/principles/layering-and-elevation/__stories__/background-elevation-colors.png similarity index 100% rename from static/app/components/core/principles/layering-and-elevation/background-elevation-colors.png rename to static/app/components/core/principles/layering-and-elevation/__stories__/background-elevation-colors.png diff --git a/static/app/components/core/principles/layering-and-elevation/chonky-borders-perspective.png b/static/app/components/core/principles/layering-and-elevation/__stories__/chonky-borders-perspective.png similarity index 100% rename from static/app/components/core/principles/layering-and-elevation/chonky-borders-perspective.png rename to static/app/components/core/principles/layering-and-elevation/__stories__/chonky-borders-perspective.png diff --git a/static/app/components/core/principles/layering-and-elevation/floating-elements-shadows.png b/static/app/components/core/principles/layering-and-elevation/__stories__/floating-elements-shadows.png similarity index 100% rename from static/app/components/core/principles/layering-and-elevation/floating-elements-shadows.png rename to static/app/components/core/principles/layering-and-elevation/__stories__/floating-elements-shadows.png diff --git a/static/app/components/core/principles/layering-and-elevation/grouped-interactive-elements.png b/static/app/components/core/principles/layering-and-elevation/__stories__/grouped-interactive-elements.png similarity index 100% rename from static/app/components/core/principles/layering-and-elevation/grouped-interactive-elements.png rename to static/app/components/core/principles/layering-and-elevation/__stories__/grouped-interactive-elements.png diff --git a/static/app/components/core/principles/layering-and-elevation/issue-details-page-example.png b/static/app/components/core/principles/layering-and-elevation/__stories__/issue-details-page-example.png similarity index 100% rename from static/app/components/core/principles/layering-and-elevation/issue-details-page-example.png rename to static/app/components/core/principles/layering-and-elevation/__stories__/issue-details-page-example.png diff --git a/static/app/components/core/principles/layering-and-elevation/monitor-config-page-example.png b/static/app/components/core/principles/layering-and-elevation/__stories__/monitor-config-page-example.png similarity index 100% rename from static/app/components/core/principles/layering-and-elevation/monitor-config-page-example.png rename to static/app/components/core/principles/layering-and-elevation/__stories__/monitor-config-page-example.png diff --git a/static/app/components/core/principles/layering-and-elevation/object-model-diagram.png b/static/app/components/core/principles/layering-and-elevation/__stories__/object-model-diagram.png similarity index 100% rename from static/app/components/core/principles/layering-and-elevation/object-model-diagram.png rename to static/app/components/core/principles/layering-and-elevation/__stories__/object-model-diagram.png diff --git a/static/app/components/core/principles/layering-and-elevation/layering-and-elevation.mdx b/static/app/components/core/principles/layering-and-elevation/layering-and-elevation.mdx index 9a87d28d259158..4d15f298caa029 100644 --- a/static/app/components/core/principles/layering-and-elevation/layering-and-elevation.mdx +++ b/static/app/components/core/principles/layering-and-elevation/layering-and-elevation.mdx @@ -4,10 +4,10 @@ layout: document description: Guidelines for creating visual hierarchy through layering, shadows, and elevation in Sentry's interface design. --- -import backgroundElevationColors from './background-elevation-colors.png'; -import chonkyBordersPerspective from './chonky-borders-perspective.png'; -import floatingElementsShadows from './floating-elements-shadows.png'; -import objectModelDiagram from './object-model-diagram.png'; +import backgroundElevationColors from './__stories__/background-elevation-colors.png'; +import chonkyBordersPerspective from './__stories__/chonky-borders-perspective.png'; +import floatingElementsShadows from './__stories__/floating-elements-shadows.png'; +import objectModelDiagram from './__stories__/object-model-diagram.png'; Layering refers to the way we "stack" elements on top of one another in the user interface. Think of UI elements — buttons, panels, overlays — as layers existing in a three-dimensional world with a depth axis that's orthogonal to the display's two real axes. diff --git a/static/app/components/core/textarea/textarea.spec.tsx b/static/app/components/core/textarea/textarea.spec.tsx index d6da188228012a..6fbb8ae6f653ae 100644 --- a/static/app/components/core/textarea/textarea.spec.tsx +++ b/static/app/components/core/textarea/textarea.spec.tsx @@ -1,6 +1,6 @@ import {render} from 'sentry-test/reactTestingLibrary'; -// eslint-disable-next-line boundaries/entry-point +// eslint-disable-next-line boundaries/dependencies import {TextArea} from './textarea'; describe('TextArea', () => { From ee6bf90705bdc58509c3a79c062c9ab9729dadf4 Mon Sep 17 00:00:00 2001 From: klochek Date: Thu, 2 Apr 2026 12:19:40 -0400 Subject: [PATCH 05/80] fix(workflows): add detector group caching in ensure_association_with_detector (#111714) --- src/sentry/workflow_engine/models/detector_group.py | 5 +++++ src/sentry/workflow_engine/processors/detector.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sentry/workflow_engine/models/detector_group.py b/src/sentry/workflow_engine/models/detector_group.py index 5bbdb4803693d7..c7396aeba6d4eb 100644 --- a/src/sentry/workflow_engine/models/detector_group.py +++ b/src/sentry/workflow_engine/models/detector_group.py @@ -1,7 +1,10 @@ +from typing import ClassVar, Self + from django.db import models from sentry.backup.scopes import RelocationScope from sentry.db.models import DefaultFieldsModel, FlexibleForeignKey, cell_silo_model +from sentry.db.models.manager.base import BaseManager @cell_silo_model @@ -15,6 +18,8 @@ class DetectorGroup(DefaultFieldsModel): detector = FlexibleForeignKey("workflow_engine.Detector", null=True, on_delete=models.SET_NULL) group = FlexibleForeignKey("sentry.Group", on_delete=models.CASCADE) + objects: ClassVar[BaseManager[Self]] = BaseManager(cache_fields=("pk", "group")) + class Meta: db_table = "workflow_engine_detectorgroup" app_label = "workflow_engine" diff --git a/src/sentry/workflow_engine/processors/detector.py b/src/sentry/workflow_engine/processors/detector.py index ecb85158c9bf97..3e89c592b311b1 100644 --- a/src/sentry/workflow_engine/processors/detector.py +++ b/src/sentry/workflow_engine/processors/detector.py @@ -636,8 +636,11 @@ def ensure_association_with_detector(group: Group, detector_id: int | None = Non return False # Common case: it exists, we verify and move on. - if DetectorGroup.objects.filter(group_id=group.id).exists(): + try: + DetectorGroup.objects.get_from_cache(group=group) return True + except DetectorGroup.DoesNotExist: + pass # Association is missing, determine the detector_id if not provided if detector_id is None: From 9ee682fc393c1594a6f531a4ea8e8c0e3d0f6c54 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Thu, 2 Apr 2026 12:27:13 -0400 Subject: [PATCH 06/80] fix(web_vitals): Add z-index to `PerformanceScoreRingTooltip` (#112111) Give `PerformanceScoreRingTooltip` the standard tooltip z-index. Otherwise it floats under other elements. --- .../webVitals/components/performanceScoreRingWithTooltips.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/views/insights/browser/webVitals/components/performanceScoreRingWithTooltips.tsx b/static/app/views/insights/browser/webVitals/components/performanceScoreRingWithTooltips.tsx index 9e347e61cdfee1..a3336fc2ebbeb1 100644 --- a/static/app/views/insights/browser/webVitals/components/performanceScoreRingWithTooltips.tsx +++ b/static/app/views/insights/browser/webVitals/components/performanceScoreRingWithTooltips.tsx @@ -383,6 +383,7 @@ const ProgressRingDiffSubText = styled(ProgressRingSubText)<{value: number}>` // Hover element on mouse const PerformanceScoreRingTooltip = styled('div')<{x: number; y: number}>` position: absolute; + z-index: ${p => p.theme.zIndex.tooltip}; background: ${p => p.theme.tokens.background.primary}; border-radius: ${p => p.theme.radius.md}; border: 1px solid ${p => p.theme.tokens.border.primary}; From 6d8a90d16507bb61c07d6694cd92c33ebddb8365 Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 2 Apr 2026 12:35:05 -0400 Subject: [PATCH 07/80] chore(search): Clean up old flow experiement (#112103) This PR cleans up an old flow experiment we tried out, but didn't end up sticking with. --------- Co-authored-by: Claude Sonnet 4 --- .../searchQueryBuilder/index.spec.tsx | 50 ++++--------------- .../searchQueryBuilder/tokens/freeText.tsx | 36 ++----------- static/app/views/automations/list.spec.tsx | 4 +- .../filterResultsStep/spansSearchBar.spec.tsx | 3 +- .../streamline/eventDetailsHeader.spec.tsx | 7 +-- .../streamline/eventSearch.spec.tsx | 8 ++- static/app/views/releases/list/index.spec.tsx | 2 +- 7 files changed, 22 insertions(+), 88 deletions(-) diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 4e82b8c027c7c7..50da5a97daea90 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -1068,9 +1068,7 @@ describe('SearchQueryBuilder', () => { it('can add a new token by clicking a key suggestion', async () => { const mockOnChange = jest.fn(); - render(, { - organization: {features: ['search-query-builder-input-flow-changes']}, - }); + render(); await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'})); await userEvent.click(screen.getByRole('option', {name: 'browser.name'})); @@ -1082,11 +1080,6 @@ describe('SearchQueryBuilder', () => { // onChange should not be called until exiting edit mode expect(mockOnChange).not.toHaveBeenCalled(); - // Should have focus on the operator option - const operatorOption = await screen.findByRole('option', {name: 'contains'}); - expect(operatorOption).toHaveFocus(); - await userEvent.click(operatorOption); - await userEvent.click(await screen.findByRole('option', {name: 'Firefox'})); // New token should have a value, and selecting from dropdown switches operator to "is" @@ -1109,8 +1102,7 @@ describe('SearchQueryBuilder', () => { , - {organization: {features: ['search-query-builder-input-flow-changes']}} + /> ); await userEvent.click( @@ -1142,9 +1134,7 @@ describe('SearchQueryBuilder', () => { }); it('can add a filter after some free text', async () => { - render(, { - organization: {features: ['search-query-builder-input-flow-changes']}, - }); + render(); await userEvent.click(getLastInput()); @@ -1156,11 +1146,6 @@ describe('SearchQueryBuilder', () => { await userEvent.click(screen.getByRole('option', {name: 'browser.name'})); jest.restoreAllMocks(); - // Should have focus on the operator option - const operatorOption = await screen.findByRole('option', {name: 'contains'}); - expect(operatorOption).toHaveFocus(); - await userEvent.click(operatorOption); - // Filter value should have focus expect(await screen.findByLabelText('Edit filter value')).toHaveFocus(); await userEvent.keyboard('foo{enter}'); @@ -1244,9 +1229,7 @@ describe('SearchQueryBuilder', () => { }); it('converts text to filter when typing :', async () => { - render(, { - organization: {features: ['search-query-builder-input-flow-changes']}, - }); + render(); await userEvent.click(getLastInput()); await userEvent.type( @@ -1276,16 +1259,13 @@ describe('SearchQueryBuilder', () => { }); it('selects [Filtered] from dropdown', async () => { - render(, { - organization: {features: ['search-query-builder-input-flow-changes']}, - }); + render(); await userEvent.click(getLastInput()); await userEvent.type( screen.getByRole('combobox', {name: 'Add a search term'}), 'message:' ); - await userEvent.keyboard('{enter}'); await userEvent.click(screen.getByRole('option', {name: '[Filtered]'})); // Selecting from dropdown switches operator from contains to "is" @@ -4197,36 +4177,24 @@ describe('SearchQueryBuilder', () => { }); it('focuses on the filter value when user selects an aggregate filter with no arguments', async () => { - render(, { - organization: {features: ['search-query-builder-input-flow-changes']}, - }); + render(); await userEvent.click(getLastInput()); await userEvent.keyboard('count'); await userEvent.click(screen.getByRole('option', {name: 'count()'})); expect(screen.getByLabelText('count():>100')).toBeInTheDocument(); - const gtOption = screen.getByRole('option', {name: '>'}); - expect(gtOption).toHaveFocus(); - await userEvent.click(gtOption); - - expect(screen.getByLabelText('Edit filter value')).toHaveFocus(); + expect(await screen.findByLabelText('Edit filter value')).toHaveFocus(); }); it('focuses on the filter value when user input looks like an aggregate filter with no arguments', async () => { - render(, { - organization: {features: ['search-query-builder-input-flow-changes']}, - }); + render(); await userEvent.click(getLastInput()); await userEvent.keyboard('count('); expect(screen.getByLabelText('count():>100')).toBeInTheDocument(); - const gtOption = screen.getByRole('option', {name: '>'}); - expect(gtOption).toHaveFocus(); - await userEvent.click(gtOption); - - expect(screen.getByLabelText('Edit filter value')).toHaveFocus(); + expect(await screen.findByLabelText('Edit filter value')).toHaveFocus(); }); it('focuses on the filter value after only argument is specified', async () => { diff --git a/static/app/components/searchQueryBuilder/tokens/freeText.tsx b/static/app/components/searchQueryBuilder/tokens/freeText.tsx index 8878dfb7702032..b222cdc53f2286 100644 --- a/static/app/components/searchQueryBuilder/tokens/freeText.tsx +++ b/static/app/components/searchQueryBuilder/tokens/freeText.tsx @@ -8,7 +8,6 @@ import type {KeyboardEvent, Node} from '@react-types/shared'; import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; import {useQueryBuilderGridItem} from 'sentry/components/searchQueryBuilder/hooks/useQueryBuilderGridItem'; import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/tokens/combobox'; -import {areWildcardOperatorsAllowed} from 'sentry/components/searchQueryBuilder/tokens/filter/utils'; import {useFilterKeyListBox} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/useFilterKeyListBox'; import {InvalidTokenTooltip} from 'sentry/components/searchQueryBuilder/tokens/invalidTokenTooltip'; import {useSortedFilterKeyItems} from 'sentry/components/searchQueryBuilder/tokens/useSortedFilterKeyItems'; @@ -27,7 +26,6 @@ import { recentSearchTypeToLabel, } from 'sentry/components/searchQueryBuilder/utils'; import { - FilterType, InvalidReason, parseSearch, Token, @@ -134,25 +132,13 @@ function countPreviousItemsOfType({ function calculateNextFocusForFilter( state: ListState, - definition: FieldDefinition | null, - key: string | null, - hasInputChangeFlows: boolean + definition: FieldDefinition | null ): FocusOverride { const numPreviousFilterItems = countPreviousItemsOfType({state, type: Token.FILTER}); - const isNumericValueType = - definition?.valueType === FieldValueType.NUMBER || - definition?.valueType === FieldValueType.INTEGER; - - let part: FocusOverride['part'] = - hasInputChangeFlows && (isNumericValueType || areWildcardOperatorsAllowed(definition)) - ? 'op' - : 'value'; - + let part: FocusOverride['part'] = 'value'; if (definition?.kind === FieldKind.FUNCTION && definition.parameters?.length) { part = 'key'; - } else if (key === FilterType.IS || key === FilterType.HAS) { - part = 'value'; } return { @@ -265,9 +251,6 @@ function SearchQueryBuilderInputInternal({ const [selectionIndex, setSelectionIndex] = useState(0); const organization = useOrganization(); - const hasInputChangeFlows = organization.features.includes( - 'search-query-builder-input-flow-changes' - ); const updateSelectionIndex = useCallback(() => { setSelectionIndex(inputRef.current?.selectionStart ?? 0); @@ -512,12 +495,7 @@ function SearchQueryBuilderInputInternal({ value, getFieldDefinition ), - focusOverride: calculateNextFocusForFilter( - state, - getFieldDefinition(value), - value, - hasInputChangeFlows - ), + focusOverride: calculateNextFocusForFilter(state, getFieldDefinition(value)), shouldCommitQuery: false, }); resetInputValue(); @@ -617,9 +595,7 @@ function SearchQueryBuilderInputInternal({ ), focusOverride: calculateNextFocusForFilter( state, - getFieldDefinition(filterValue), - null, - hasInputChangeFlows + getFieldDefinition(filterValue) ), shouldCommitQuery: false, }); @@ -660,9 +636,7 @@ function SearchQueryBuilderInputInternal({ ), focusOverride: calculateNextFocusForFilter( state, - getFieldDefinition(filterKey), - filterKey, - hasInputChangeFlows + getFieldDefinition(filterKey) ), shouldCommitQuery: false, }); diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index 8210c982d96930..576ed27a9b610b 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -25,7 +25,7 @@ import AutomationsList from 'sentry/views/automations/list'; describe('AutomationsList', () => { const organization = OrganizationFixture({ - features: ['workflow-engine-ui', 'search-query-builder-input-flow-changes'], + features: ['workflow-engine-ui'], }); const project = ProjectFixture({id: '1', slug: 'project-1'}); const detector = MetricDetectorFixture({ @@ -180,7 +180,6 @@ describe('AutomationsList', () => { // Click through menus to select action:slack await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'})); await userEvent.click(await screen.findByRole('option', {name: 'action'})); - await userEvent.click(await screen.findByRole('option', {name: 'is'})); await userEvent.click(await screen.findByRole('option', {name: 'slack'})); await screen.findByText('Slack Automation'); @@ -433,7 +432,6 @@ describe('AutomationsList', () => { // Click through menus to select action:slack await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'})); await userEvent.click(await screen.findByRole('option', {name: 'action'})); - await userEvent.click(await screen.findByRole('option', {name: 'is'})); await userEvent.click(await screen.findByRole('option', {name: 'slack'})); // Wait for filtered results to load diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx index 0ccb3c9dadf73d..976a90cfdd514a 100644 --- a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx @@ -21,7 +21,7 @@ function renderWithProvider({ }: ComponentProps) { return render( , - {organization: {features: ['search-query-builder-input-flow-changes']}} + {} ); } @@ -135,7 +135,6 @@ describe('SpansSearchBar', () => { name: 'Add a search term', }); await userEvent.type(searchInput, 'span.op:', {delay: null}); - await userEvent.keyboard('{enter}'); await userEvent.keyboard('function', {delay: null}); await userEvent.keyboard('{enter}'); diff --git a/static/app/views/issueDetails/streamline/eventDetailsHeader.spec.tsx b/static/app/views/issueDetails/streamline/eventDetailsHeader.spec.tsx index 058f2d114de9d4..f2e6743c7b9827 100644 --- a/static/app/views/issueDetails/streamline/eventDetailsHeader.spec.tsx +++ b/static/app/views/issueDetails/streamline/eventDetailsHeader.spec.tsx @@ -25,9 +25,7 @@ jest.mock('sentry/views/issueDetails/utils', () => ({ })); describe('EventDetailsHeader', () => { - const organization = OrganizationFixture({ - features: ['search-query-builder-input-flow-changes'], - }); + const organization = OrganizationFixture(); const project = ProjectFixture({ environments: ['production', 'staging', 'development'], }); @@ -139,8 +137,7 @@ describe('EventDetailsHeader', () => { const search = await screen.findByPlaceholderText('Filter events\u2026'); await userEvent.type(search, `${tagKey}:`, {delay: null}); - await userEvent.click(screen.getByRole('option', {name: 'is'})); - await userEvent.keyboard(`${tagValue}{enter}`, {delay: null}); + await userEvent.click(await screen.findByRole('option', {name: tagValue})); await waitFor(() => { expect(mockUseNavigate).toHaveBeenCalledWith( expect.objectContaining(locationQuery), diff --git a/static/app/views/issueDetails/streamline/eventSearch.spec.tsx b/static/app/views/issueDetails/streamline/eventSearch.spec.tsx index 43afcd1e020cf9..1784fa73f79174 100644 --- a/static/app/views/issueDetails/streamline/eventSearch.spec.tsx +++ b/static/app/views/issueDetails/streamline/eventSearch.spec.tsx @@ -11,9 +11,7 @@ import {EventSearch} from 'sentry/views/issueDetails/streamline/eventSearch'; const mockHandleSearch = jest.fn(); describe('EventSearch', () => { - const organization = OrganizationFixture({ - features: ['search-query-builder-input-flow-changes'], - }); + const organization = OrganizationFixture(); const project = ProjectFixture({ environments: ['production', 'staging', 'developement'], }); @@ -53,8 +51,8 @@ describe('EventSearch', () => { const search = screen.getByRole('combobox', {name: 'Add a search term'}); expect(search).toBeInTheDocument(); await userEvent.type(search, `${tagKey}:`); - await userEvent.click(screen.getByRole('option', {name: 'is'})); - await userEvent.keyboard(`${tagValue}{enter}{enter}`); + await userEvent.click(await screen.findByRole('option', {name: tagValue})); + await userEvent.keyboard('{enter}'); await waitFor(() => { expect(mockTagKeyQuery).toHaveBeenCalled(); diff --git a/static/app/views/releases/list/index.spec.tsx b/static/app/views/releases/list/index.spec.tsx index 621f38cf59c2ff..61d597152be6c2 100644 --- a/static/app/views/releases/list/index.spec.tsx +++ b/static/app/views/releases/list/index.spec.tsx @@ -21,7 +21,7 @@ import {ReleasesStatusOption} from 'sentry/views/releases/list/releasesStatusOpt describe('ReleasesList', () => { const organization = OrganizationFixture({ - features: ['search-query-builder-input-flow-changes', 'preprod-frontend-routes'], + features: ['preprod-frontend-routes'], }); const projects = [ProjectFixture({features: ['releases']})]; const semverVersionInfo = { From b1fb6641c438ea52562c11789cb6efdf8acbb54d Mon Sep 17 00:00:00 2001 From: Nick Date: Thu, 2 Apr 2026 12:38:03 -0400 Subject: [PATCH 08/80] fix(cross-events): Enable raw-search replacement (#112099) This PR fixes up the issue where we don't have raw-search replacement working properly on the cross-event search bars, so users were getting a broken experience. Ticket: EXP-854 Co-authored-by: Claude Sonnet 4.6 --- .../app/views/explore/spans/spansTabSearchSection.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/static/app/views/explore/spans/spansTabSearchSection.tsx b/static/app/views/explore/spans/spansTabSearchSection.tsx index 31880086f743c1..1c4f0593bcc008 100644 --- a/static/app/views/explore/spans/spansTabSearchSection.tsx +++ b/static/app/views/explore/spans/spansTabSearchSection.tsx @@ -128,6 +128,10 @@ const SpansTabCrossEventSearchBar = memo( const mode = useQueryParamsMode(); const crossEvents = useQueryParamsCrossEvents(); const setCrossEvents = useSetQueryParamsCrossEvents(); + const organization = useOrganization(); + const hasRawSearchReplacement = organization.features.includes( + 'search-query-builder-raw-search-replacement' + ); const traceItemType = type === 'logs' ? TraceItemDataset.LOGS : TraceItemDataset.SPANS; @@ -178,11 +182,17 @@ const SpansTabCrossEventSearchBar = memo( booleanSecondaryAliases, numberSecondaryAliases, stringSecondaryAliases, + replaceRawSearchKeys: hasRawSearchReplacement + ? type === 'logs' + ? ['message'] + : ['span.description'] + : undefined, }), [ booleanAttributes, booleanSecondaryAliases, crossEvents, + hasRawSearchReplacement, index, mode, numberSecondaryAliases, From 3e9d94cccda41153059ef4004e17c31f4236f748 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Thu, 2 Apr 2026 09:40:46 -0700 Subject: [PATCH 09/80] fix(preprod): Use recompare endpoint and add user-facing status check rerun (#112084) ## Summary Two changes to the snapshot header actions menu: 1. **Admin "Re-run comparison"** now calls the `recompare` endpoint instead of `rerun-status-checks`. This actually re-runs the full image diff pipeline (and automatically re-posts the GitHub status check when complete). 2. **New user-facing "Rerun Status Checks"** menu item lets regular users re-post the GitHub status check without needing admin access or re-running the full image diff. ## Test plan - Verify "Rerun Status Checks" is visible to all users and calls `rerun-status-checks` with `check_types: ['snapshots']` - Verify "Re-run comparison" is only visible to Sentry employees and calls the `recompare` endpoint - Confirm the snapshot comparison is re-run with fresh image diffs when using the admin action Co-authored-by: Claude Opus 4.6 --- .../header/snapshotHeaderActions.tsx | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx b/static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx index 3ec9bb755f69ee..f3cf09a6a3bd57 100644 --- a/static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx +++ b/static/app/views/preprod/snapshots/header/snapshotHeaderActions.tsx @@ -86,12 +86,28 @@ export function SnapshotHeaderActions({ ); }, [organizationSlug, data.head_artifact_id, queryClient, apiUrl]); - const handleRerunComparison = useCallback(() => { + const handleRerunStatusChecks = useCallback(() => { clientRef.current.request( `/organizations/${organizationSlug}/preprod-artifact/rerun-status-checks/${data.head_artifact_id}/`, { method: 'POST', data: {check_types: ['snapshots']}, + success: () => { + addSuccessMessage(t('Status checks rerun initiated')); + queryClient.invalidateQueries({queryKey: [apiUrl]}); + }, + error: (_resp: any) => { + addErrorMessage(t('Failed to rerun status checks')); + }, + } + ); + }, [organizationSlug, data.head_artifact_id, queryClient, apiUrl]); + + const handleRerunComparison = useCallback(() => { + clientRef.current.request( + `/organizations/${organizationSlug}/preprodartifacts/snapshots/${data.head_artifact_id}/recompare/`, + { + method: 'POST', success: () => { addSuccessMessage(t('Re-run comparison initiated')); queryClient.invalidateQueries({queryKey: [apiUrl]}); @@ -165,6 +181,17 @@ export function SnapshotHeaderActions({ > {({open: openDeleteModal}) => { const menuItems: MenuItemProps[] = [ + { + key: 'rerun-status-checks', + label: ( + + + {t('Rerun Status Checks')} + + ), + onAction: handleRerunStatusChecks, + textValue: t('Rerun Status Checks'), + }, { key: 'delete', label: ( From 2c44e5b2e4721766fae25f25e81af1d6baf7116c Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Thu, 2 Apr 2026 12:43:13 -0400 Subject: [PATCH 10/80] feat(scraps): indeterminate loader (#111369) blocked by https://github.com/getsentry/sentry/pull/112039 adds an indeterminate loader to our design system and hooks it up to our button ## [`IndeterminateLoader`](https://sentry-git-scraps-loader.sentry.dev/stories/core/loader/) https://github.com/user-attachments/assets/312c1d5a-f4ef-4d80-9844-611529502a1d ## [` - - - ```jsx + - - - +``` + +### Busy Buttons + +Busy buttons should be used to indicate that an async action is in progress, usually connected to the `isPending` state of a query mutation. + +export function BusyDemo() { + const [busy, setBusy] = useState({cancel: false, submit: false}); + /** @param key {'cancel' | 'submit'} */ + const handleClick = key => { + setBusy(v => ({...v, [key]: true})); + setTimeout(() => { + setBusy(v => ({...v, [key]: false})); + }, 2500); + }; + return ( + + + + + ); +} + + + + +```jsx + + + + ``` ## Icon-only Buttons diff --git a/static/app/components/core/button/button.tsx b/static/app/components/core/button/button.tsx index c305a65ee421a1..7f2d698ccdf669 100644 --- a/static/app/components/core/button/button.tsx +++ b/static/app/components/core/button/button.tsx @@ -1,11 +1,14 @@ -import {keyframes} from '@emotion/react'; +import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; +import {AnimatePresence, motion} from 'framer-motion'; import {Flex} from '@sentry/scraps/layout'; +import {IndeterminateLoader} from '@sentry/scraps/loader'; import {useSizeContext} from '@sentry/scraps/sizeContext'; import {Tooltip} from '@sentry/scraps/tooltip'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; +import {testableTransition} from 'sentry/utils/testableTransition'; import { DO_NOT_USE_BUTTON_ICON_SIZES as BUTTON_ICON_SIZES, @@ -14,6 +17,8 @@ import { import type {DO_NOT_USE_ButtonProps as ButtonProps} from './types'; import {useButtonFunctionality} from './useButtonFunctionality'; +const MotionFlex = motion.create(Flex); + export type {ButtonProps}; export function Button({ @@ -26,6 +31,7 @@ export function Button({ }: ButtonProps) { const contextSize = useSizeContext(); const size = explicitSize ?? contextSize ?? 'md'; + const theme = useTheme(); const {handleClick, hasChildren, accessibleLabel} = useButtonFunctionality({ ...props, type, @@ -59,36 +65,69 @@ export function Button({ justify="center" minWidth="0" height="100%" + overflow="visible" whiteSpace="nowrap" - visibility={busy ? 'hidden' : undefined} > - {props.icon && ( - - )} - {props.children} - {busy && ( - - {({className}) => } - - )} + + {props.icon && ( + + )} + {props.children} + + + {busy && ( + + + + )} +
@@ -103,22 +142,3 @@ const StyledButton = styled('button')< >` ${p => getButtonStyles(p)} `; - -const spin = keyframes` - to { - transform: rotate(360deg); - } -`; - -const BusySpinner = styled('span')` - &::after { - content: ''; - display: block; - width: 1em; - height: 1em; - border-radius: 50%; - border: 2px solid currentColor; - border-top-color: transparent; - animation: ${spin} 0.6s linear infinite; - } -`; diff --git a/static/app/components/core/button/styles.tsx b/static/app/components/core/button/styles.tsx index 82f6c9f5bc57a0..d206c1962e593e 100644 --- a/static/app/components/core/button/styles.tsx +++ b/static/app/components/core/button/styles.tsx @@ -75,7 +75,7 @@ export function DO_NOT_USE_getButtonStyles( fontWeight: p.theme.font.weight.sans.medium, - opacity: p.busy || p.disabled ? 0.6 : undefined, + opacity: p.disabled ? 0.6 : undefined, cursor: 'pointer', '&[disabled]': { @@ -135,6 +135,10 @@ export function DO_NOT_USE_getButtonStyles( }, }, + '&[aria-busy="true"] > span:last-child': { + overflow: 'visible', + }, + '> span:last-child': { zIndex: 1, position: 'relative', @@ -180,7 +184,7 @@ export function DO_NOT_USE_getButtonStyles( }, }, - '&:disabled, &[aria-disabled="true"]': { + '&:disabled, &[aria-disabled="true"], &[aria-busy="true"]': { '&::after': { transform: 'translateY(0px)', }, @@ -189,6 +193,10 @@ export function DO_NOT_USE_getButtonStyles( }, }, + '&[aria-busy="true"]': { + cursor: 'progress', + }, + ...(p.priority === 'link' && { transform: 'translateY(0px)', diff --git a/static/app/components/core/loader/indeterminateLoader.tsx b/static/app/components/core/loader/indeterminateLoader.tsx new file mode 100644 index 00000000000000..bf723e44a29098 --- /dev/null +++ b/static/app/components/core/loader/indeterminateLoader.tsx @@ -0,0 +1,215 @@ +import {useEffect, useRef, useState} from 'react'; +import {keyframes} from '@emotion/react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import {useResizeObserver} from '@react-aria/utils'; +import {AnimatePresence, motion} from 'framer-motion'; + +import {Stack} from '@sentry/scraps/layout'; + +import {testableTransition} from 'sentry/utils/testableTransition'; + +// required to break import cycle +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import {Text} from '../text/text'; + +interface IndeterminateLoaderProps extends React.HTMLAttributes { + messages?: React.ReactNode[]; + variant?: 'vibrant' | 'monochrome'; +} + +const SQUIGGLE_TILE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='1 0 16 8'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M17 6c-4 0-4-4-8-4S5 6 1 6'/%3E%3C/svg%3E")`; + +const indeterminateSlow = keyframes` + 0% { left: -35%; right: 100%; } + 60% { left: 100%; right: -90%; } + 100% { left: 100%; right: -90%; } +`; + +const indeterminateFast = keyframes` + 0% { left: -200%; right: 100%; } + 60% { left: 107%; right: -8%; } + 100% { left: 107%; right: -8%; } +`; + +// Lerp animation timing based on track width. +// Small (~128px): 2.0s duration, 1.0s delay +// Large (~400px+): 3.2s duration, 1.6s delay +const WIDTH = {MIN: 128, MAX: 400}; +const DURATION = {MIN: 2.0, MAX: 2.8}; +const DELAY = {MIN: 0.8, MAX: 1.2}; + +function lerp(min: number, max: number, t: number): number { + return min + (max - min) * Math.min(1, Math.max(0, t)); +} + +function useAnimationTiming() { + const ref = useRef(null); + const [duration, setDuration] = useState(DURATION.MAX); + const [delay, setDelay] = useState(DELAY.MAX); + + useResizeObserver({ + ref, + onResize() { + const w = ref.current?.offsetWidth ?? WIDTH.MAX; + const t = (w - WIDTH.MIN) / (WIDTH.MAX - WIDTH.MIN); + setDuration(lerp(DURATION.MIN, DURATION.MAX, t)); + setDelay(lerp(DELAY.MIN, DELAY.MAX, t)); + }, + }); + + return {ref, duration, delay}; +} + +const MESSAGE_INTERVAL_MS = 10_000; + +function useMessageCycler(messages: React.ReactNode[]) { + const [index, setIndex] = useState(0); + + useEffect(() => { + if (messages.length <= 1 || index >= messages.length - 1) { + return undefined; + } + const timer = setTimeout(() => setIndex(i => i + 1), MESSAGE_INTERVAL_MS); + return () => clearTimeout(timer); + }, [index, messages.length]); + + return {message: messages.length > 0 ? messages[index] : null, index}; +} + +export function IndeterminateLoader({ + variant = 'vibrant', + messages, + ...props +}: IndeterminateLoaderProps) { + const theme = useTheme(); + const {ref, duration, delay} = useAnimationTiming(); + const {message: currentMessage, index: messageIndex} = useMessageCycler(messages ?? []); + + const track = ( + + + + + + + ); + + if (!messages?.length) { + return track; + } + + return ( + + {track} + + + + {currentMessage} + + + + + + ); +} + +const dotFadeInOut = keyframes` + 0%, 30% { opacity: 0; } + 40%, 70% { opacity: 1; } + 80%, 100% { opacity: 0; } +`; + +function Ellipsis() { + return ( + + . + . + . + + ); +} + +const Dot = styled('span')<{delay: number}>` + opacity: 0; + animation: ${dotFadeInOut} 2.5s ${p => p.delay}s infinite; +`; + +const Track = styled('div')<{color: string; opacity: string}>` + position: relative; + overflow: hidden; + width: 100%; + width: calc(round(down, 100% - 16px, 8px) + 16px); + height: 8px; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: ${p => p.color}; + opacity: ${p => p.opacity}; + mask-image: ${SQUIGGLE_TILE}; + mask-repeat: repeat-x; + mask-size: 16px 8px; + -webkit-mask-image: ${SQUIGGLE_TILE}; + -webkit-mask-repeat: repeat-x; + -webkit-mask-size: 16px 8px; + } +`; + +const ColorMask = styled('span')` + position: absolute; + inset: 0; + mask-image: ${SQUIGGLE_TILE}; + mask-repeat: repeat-x; + mask-size: 16px 8px; + -webkit-mask-image: ${SQUIGGLE_TILE}; + -webkit-mask-repeat: repeat-x; + -webkit-mask-size: 16px 8px; +`; + +const Bar = styled('span')<{ + animation: ReturnType; + color: string; + delay: string; + duration: string; + timing: string; +}>` + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: ${p => p.color}; + animation: ${p => p.animation} ${p => p.duration} ${p => p.timing} ${p => p.delay} + infinite backwards; +`; diff --git a/static/app/components/core/loader/index.tsx b/static/app/components/core/loader/index.tsx new file mode 100644 index 00000000000000..f763feb1695b1c --- /dev/null +++ b/static/app/components/core/loader/index.tsx @@ -0,0 +1 @@ +export {IndeterminateLoader} from './indeterminateLoader'; diff --git a/static/app/components/core/loader/loader.mdx b/static/app/components/core/loader/loader.mdx new file mode 100644 index 00000000000000..cb33feafb2e88a --- /dev/null +++ b/static/app/components/core/loader/loader.mdx @@ -0,0 +1,108 @@ +--- +title: IndeterminateLoader +description: An animated squiggle loader that fills its container to indicate indeterminate progress. +category: status +source: '@sentry/scraps/loader' +resources: + js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/loader/indeterminateLoader.tsx +--- + +import {Container, Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {IndeterminateLoader} from '@sentry/scraps/loader'; + +import {Demo} from 'sentry/stories/demo'; + +export const documentation = import('!!type-loader!@sentry/scraps/loader'); + +The `IndeterminateLoader` renders a repeating squiggle pattern with an animated color wipe to indicate indeterminate loading progress. It fills 100% of its parent's width. + +## Usage + + + + + +```jsx + +``` + +## Messages + +Pass an array of `messages` to step through loading messages as the loader runs. + + + + + +```jsx + +``` + +## Contained Width + +Constrain the loader's width by wrapping it in a sized container. + + + + + + + + + + + + + +```jsx + + + + + + + + + +``` + +## Monochrome + +Use `variant="monochrome"` to inherit `currentColor` from the parent. The track renders at 25% opacity and the accent at full opacity. + +This is used internally in the Button component. + + + + + + + + + + + + + +```jsx + + + +``` + +## Accessibility + +The component renders with `role="progressbar"` and a default `aria-label` of `"Loading"`. Override the label to provide more specific context: + +```jsx + +``` From d4baa33730a78adffba90d2fbeba3707162b935c Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 2 Apr 2026 09:49:06 -0700 Subject: [PATCH 11/80] perf(vercel): Skip gzip pre-compression and cache hashed assets in deploy previews (#112108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip `CompressionPlugin` for Vercel deploy previews. The `.gz` files are generated for nginx's `gzip_static` module which Vercel doesn't use — it compresses responses at the edge instead. Also adds `Cache-Control: public, max-age=31536000, immutable` headers for content-hashed assets (`chunks/` and `assets/` directories) served from deploy previews. saves maybe 10 seconds on build Co-authored-by: Claude Opus 4.6 --- rspack.config.ts | 21 ++++++++++++--------- vercel.json | 9 +++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/rspack.config.ts b/rspack.config.ts index 0621b035e0fe17..24f35a0950414d 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -846,15 +846,18 @@ if (IS_UI_DEV_ONLY || SENTRY_EXPERIMENTAL_SPA) { } if (IS_PRODUCTION) { - // This compression-webpack-plugin generates pre-compressed files - // ending in .gz, to be picked up and served by our internal static media - // server as well as nginx when paired with the gzip_static module. - appConfig.plugins?.push( - new CompressionPlugin({ - algorithm: 'gzip', - test: /\.(js|map|css|svg|html|txt|ico|eot|ttf)$/, - }) - ); + if (!IS_DEPLOY_PREVIEW) { + // This compression-webpack-plugin generates pre-compressed files + // ending in .gz, to be picked up and served by our internal static media + // server as well as nginx when paired with the gzip_static module. + // Skipped for deploy previews since Vercel handles compression itself. + appConfig.plugins?.push( + new CompressionPlugin({ + algorithm: 'gzip', + test: /\.(js|map|css|svg|html|txt|ico|eot|ttf)$/, + }) + ); + } // Enable sentry-webpack-plugin for production builds appConfig.plugins?.push( diff --git a/vercel.json b/vercel.json index d1a52b12d606d3..56335717787179 100644 --- a/vercel.json +++ b/vercel.json @@ -18,6 +18,15 @@ { "source": "/api/(.*)", "headers": [{"key": "Referer", "value": "https://sentry.io/"}] + }, + { + "source": "/_assets/(chunks|assets)/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] } ], "github": {"silent": true} From 11c6c73994d04b97c13a8fff454dd04a15ee2000 Mon Sep 17 00:00:00 2001 From: Sentry Bot Date: Thu, 2 Apr 2026 09:58:02 -0700 Subject: [PATCH 12/80] ref: bump sentry-arroyo to 2.38.7 (#112117) Co-Authored-By: bmckerry <110857332+bmckerry@users.noreply.github.com> Co-authored-by: getsentry-bot <10587625+getsentry-bot@users.noreply.github.com> Co-authored-by: bmckerry <110857332+bmckerry@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 704559ec8e75d8..46b1f3728e6103 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "rfc3339-validator>=0.1.2", "rfc3986-validator>=0.1.1", # [end] jsonschema format validators - "sentry-arroyo>=2.38.5", + "sentry-arroyo>=2.38.7", "sentry-conventions>=0.3.0", "sentry-forked-email-reply-parser>=0.5.12.post1", "sentry-kafka-schemas>=2.1.27", diff --git a/uv.lock b/uv.lock index d4c5e7b677ace0..20e8272ad51e56 100644 --- a/uv.lock +++ b/uv.lock @@ -2367,7 +2367,7 @@ requires-dist = [ { name = "requests-oauthlib", specifier = ">=1.2.0" }, { name = "rfc3339-validator", specifier = ">=0.1.2" }, { name = "rfc3986-validator", specifier = ">=0.1.1" }, - { name = "sentry-arroyo", specifier = ">=2.38.5" }, + { name = "sentry-arroyo", specifier = ">=2.38.7" }, { name = "sentry-conventions", specifier = ">=0.3.0" }, { name = "sentry-forked-email-reply-parser", specifier = ">=0.5.12.post1" }, { name = "sentry-kafka-schemas", specifier = ">=2.1.27" }, @@ -2458,13 +2458,13 @@ dev = [ [[package]] name = "sentry-arroyo" -version = "2.38.5" +version = "2.38.7" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "confluent-kafka", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/sentry_arroyo-2.38.5-py3-none-any.whl", hash = "sha256:add69f320b7065d675aa9f8caae65e03d35d1241534871caeff70de54ed4f33e" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_arroyo-2.38.7-py3-none-any.whl", hash = "sha256:088f8620e1fa6af950d588e4ddae259b849fb799af9a78dd5b9912ccedd19a4a" }, ] [[package]] From 2d459063ea27c7211074a0a0501d6a91cef8ef11 Mon Sep 17 00:00:00 2001 From: Kyle Consalus Date: Thu, 2 Apr 2026 10:05:29 -0700 Subject: [PATCH 13/80] perf(workflows): Unify AlertRuleDetectory querying in WorkflowEngineDetectorSerializer (#112071) Avoids yet another N+1. --- .../serializers/workflow_engine_detector.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py b/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py index 66bc79545609ed..050a016f30cf0d 100644 --- a/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py +++ b/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py @@ -88,17 +88,16 @@ def add_triggers_and_actions( detectors: dict[int, Detector], sentry_app_installations_by_sentry_app_id: Mapping[str, RpcSentryAppComponentContext], serialized_data_conditions: list[dict[str, Any]], + detector_ids_by_alert_rule_id: dict[int, int], ) -> None: for serialized in serialized_data_conditions: errors = [] alert_rule_id = serialized.get("alertRuleId") assert alert_rule_id - try: - detector_id = AlertRuleDetector.objects.values_list("detector_id", flat=True).get( - alert_rule_id=alert_rule_id - ) - except AlertRuleDetector.DoesNotExist: - detector_id = get_object_id_from_fake_id(int(alert_rule_id)) + detector_id = detector_ids_by_alert_rule_id.get( + int(alert_rule_id), + get_object_id_from_fake_id(int(alert_rule_id)), + ) detector = detectors[int(detector_id)] alert_rule_triggers = result[detector].setdefault("triggers", []) @@ -273,6 +272,16 @@ def get_attrs( self.add_sentry_app_installations_by_sentry_app_id(actions, organization_id) ) + # Batch-fetch AlertRuleDetector mappings (used by add_triggers_and_actions and serialize) + alert_rule_ids_by_detector_id = dict( + AlertRuleDetector.objects.filter(detector_id__in=detector_ids).values_list( + "detector_id", "alert_rule_id" + ) + ) + detector_ids_by_alert_rule_id: dict[int, int] = { + v: k for k, v in alert_rule_ids_by_detector_id.items() if v is not None + } + # add trigger and action data # Evaluate queryset once and reuse for both serialization and lookup dict detector_trigger_data_conditions_list = list(detector_trigger_data_conditions) @@ -287,6 +296,7 @@ def get_attrs( detectors, sentry_app_installations_by_sentry_app_id, serialized_data_conditions, + detector_ids_by_alert_rule_id, ) # derive thresholdType and sensitivity/seasonality from trigger data conditions # Build a dict to avoid N queries when looking up by condition_group_id @@ -317,11 +327,6 @@ def get_attrs( self.add_created_by(result, list(detectors.values())) self.add_owner(result, list(detectors.values())) - alert_rule_ids_by_detector_id = dict( - AlertRuleDetector.objects.filter(detector_id__in=detector_ids).values_list( - "detector_id", "alert_rule_id" - ) - ) for detector in detectors.values(): result[detector]["alert_rule_id"] = alert_rule_ids_by_detector_id.get(detector.id) From ce286c615a6faec55a93e7881fd71c0228df1e2d Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 2 Apr 2026 10:06:10 -0700 Subject: [PATCH 14/80] feat(ui): Port eslint-plugin-sentry to this repo (#112081) These lint rules are only used with sentry, add them locally. originally they were in this repo https://github.com/getsentry/eslint-config-sentry/tree/master/packages/eslint-plugin-sentry --- eslint.config.ts | 15 +---- package.json | 1 - pnpm-lock.yaml | 11 ---- static/eslint/eslintPluginSentry/index.ts | 6 ++ .../no-digits-in-tn.spec.ts | 24 ++++++++ .../eslintPluginSentry/no-digits-in-tn.ts | 40 +++++++++++++ .../no-dynamic-translations.spec.ts | 24 ++++++++ .../no-dynamic-translations.ts | 57 +++++++++++++++++++ .../no-styled-shortcut.spec.ts | 16 ++++++ .../eslintPluginSentry/no-styled-shortcut.ts | 43 ++++++++++++++ 10 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 static/eslint/eslintPluginSentry/no-digits-in-tn.spec.ts create mode 100644 static/eslint/eslintPluginSentry/no-digits-in-tn.ts create mode 100644 static/eslint/eslintPluginSentry/no-dynamic-translations.spec.ts create mode 100644 static/eslint/eslintPluginSentry/no-dynamic-translations.ts create mode 100644 static/eslint/eslintPluginSentry/no-styled-shortcut.spec.ts create mode 100644 static/eslint/eslintPluginSentry/no-styled-shortcut.ts diff --git a/eslint.config.ts b/eslint.config.ts index 1e428637b610eb..824337252346ba 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -38,8 +38,6 @@ import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import reactYouMightNotNeedAnEffect from 'eslint-plugin-react-you-might-not-need-an-effect'; import regexp from 'eslint-plugin-regexp'; -// @ts-expect-error TS(7016): Could not find a declaration file -import sentry from 'eslint-plugin-sentry'; import testingLibrary from 'eslint-plugin-testing-library'; // @ts-expect-error TS (7016): Could not find a declaration file import typescriptSortKeys from 'eslint-plugin-typescript-sort-keys'; @@ -458,7 +456,10 @@ export default typescript.config([ name: 'plugin/@sentry/sentry', plugins: {'@sentry': sentryPlugin}, rules: { + '@sentry/no-digits-in-tn': 'error', + '@sentry/no-dynamic-translations': 'error', '@sentry/no-static-translations': 'error', + '@sentry/no-styled-shortcut': 'error', }, }, { @@ -747,16 +748,6 @@ export default typescript.config([ ], }, }, - { - name: 'plugin/sentry', - // https://github.com/getsentry/eslint-config-sentry/tree/master/packages/eslint-plugin-sentry/docs/rules - plugins: {sentry}, - rules: { - 'sentry/no-digits-in-tn': 'error', - 'sentry/no-dynamic-translations': 'error', // TODO(ryan953): There are no docs for this rule - 'sentry/no-styled-shortcut': 'error', - }, - }, { name: 'plugin/@emotion', // https://github.com/emotion-js/emotion/tree/main/packages/eslint-plugin/docs/rules diff --git a/package.json b/package.json index 66c00ae0a97422..7caf31bb9e0f24 100644 --- a/package.json +++ b/package.json @@ -266,7 +266,6 @@ "eslint-plugin-react-hooks": "6.1.0", "eslint-plugin-react-you-might-not-need-an-effect": "0.5.3", "eslint-plugin-regexp": "^3.0.0", - "eslint-plugin-sentry": "^2.10.0", "eslint-plugin-testing-library": "^7.16.0", "eslint-plugin-typescript-sort-keys": "^3.3.0", "eslint-plugin-unicorn": "^57.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f58f5feb8ceef..9312bc3eaea0df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -655,9 +655,6 @@ importers: eslint-plugin-regexp: specifier: ^3.0.0 version: 3.0.0(eslint@9.34.0(jiti@2.6.1)) - eslint-plugin-sentry: - specifier: ^2.10.0 - version: 2.10.0 eslint-plugin-testing-library: specifier: ^7.16.0 version: 7.16.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3) @@ -5797,10 +5794,6 @@ packages: peerDependencies: eslint: '>=9.38.0' - eslint-plugin-sentry@2.10.0: - resolution: {integrity: sha512-QuCUr2B78Onr2GdXM4ncPlS2IBAB+lL5QwSPNvX5LcAoBtV7iFuhEY7DS4WqwgmJcRnI6g3/dKARpFuOILR35A==} - engines: {node: '>=0.10.0'} - eslint-plugin-testing-library@7.16.0: resolution: {integrity: sha512-lHZI6/Olb2oZqxd1+s1nOLCtL2PXKrc1ERz6oDbUKS0xZAMFH3Fy6wJo75z3pXTop3BV6+loPi2MSjIYt3vpAg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -15878,10 +15871,6 @@ snapshots: regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-sentry@2.10.0: - dependencies: - requireindex: 1.2.0 - eslint-plugin-testing-library@7.16.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/scope-manager': 8.56.1 diff --git a/static/eslint/eslintPluginSentry/index.ts b/static/eslint/eslintPluginSentry/index.ts index 3612d5a3d2f69d..5cb811d210125d 100644 --- a/static/eslint/eslintPluginSentry/index.ts +++ b/static/eslint/eslintPluginSentry/index.ts @@ -1,9 +1,15 @@ import {noDefaultExports} from './no-default-exports'; +import {noDigitsInTn} from './no-digits-in-tn'; +import {noDynamicTranslations} from './no-dynamic-translations'; import {noStaticTranslations} from './no-static-translations'; +import {noStyledShortcut} from './no-styled-shortcut'; import {noUnnecessaryTypeAnnotation} from './no-unnecessary-type-annotation'; export const rules = { 'no-default-exports': noDefaultExports, + 'no-digits-in-tn': noDigitsInTn, + 'no-dynamic-translations': noDynamicTranslations, 'no-static-translations': noStaticTranslations, + 'no-styled-shortcut': noStyledShortcut, 'no-unnecessary-type-annotation': noUnnecessaryTypeAnnotation, }; diff --git a/static/eslint/eslintPluginSentry/no-digits-in-tn.spec.ts b/static/eslint/eslintPluginSentry/no-digits-in-tn.spec.ts new file mode 100644 index 00000000000000..6cfff58691124e --- /dev/null +++ b/static/eslint/eslintPluginSentry/no-digits-in-tn.spec.ts @@ -0,0 +1,24 @@ +import {RuleTester} from '@typescript-eslint/rule-tester'; + +import {noDigitsInTn} from './no-digits-in-tn'; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-digits-in-tn', noDigitsInTn, { + valid: [ + {code: "tn('%s project', '%s projects', 5)"}, + {code: "t('%d is fine outside tn')"}, + ], + invalid: [ + { + code: "tn('%d project', '%d projects', 5)", + output: "tn('%s project', '%s projects', 5)", + errors: [{messageId: 'noDigits'}, {messageId: 'noDigits'}], + }, + { + code: "tn('%d items\\n', '%d items\\n', count)", + output: "tn('%s items\\n', '%s items\\n', count)", + errors: [{messageId: 'noDigits'}, {messageId: 'noDigits'}], + }, + ], +}); diff --git a/static/eslint/eslintPluginSentry/no-digits-in-tn.ts b/static/eslint/eslintPluginSentry/no-digits-in-tn.ts new file mode 100644 index 00000000000000..d796fd9b8b5043 --- /dev/null +++ b/static/eslint/eslintPluginSentry/no-digits-in-tn.ts @@ -0,0 +1,40 @@ +import {AST_NODE_TYPES, ESLintUtils} from '@typescript-eslint/utils'; + +export const noDigitsInTn = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'suggestion', + docs: { + description: "Disallow using '%d' within 'tn()' — use '%s' instead", + }, + fixable: 'code', + schema: [], + messages: { + noDigits: "Do not use '%d' within 'tn()'. Use '%s' instead.", + }, + }, + create(context) { + return { + CallExpression(node) { + if (node.callee.type !== AST_NODE_TYPES.Identifier || node.callee.name !== 'tn') { + return; + } + + for (const argument of node.arguments) { + if ( + argument.type === AST_NODE_TYPES.Literal && + typeof argument.value === 'string' && + argument.value.includes('%d') + ) { + context.report({ + node, + messageId: 'noDigits', + fix(fixer) { + return fixer.replaceText(argument, argument.raw.replace(/%d/g, '%s')); + }, + }); + } + } + }, + }; + }, +}); diff --git a/static/eslint/eslintPluginSentry/no-dynamic-translations.spec.ts b/static/eslint/eslintPluginSentry/no-dynamic-translations.spec.ts new file mode 100644 index 00000000000000..c7aa5dab1c7c5f --- /dev/null +++ b/static/eslint/eslintPluginSentry/no-dynamic-translations.spec.ts @@ -0,0 +1,24 @@ +import {RuleTester} from '@typescript-eslint/rule-tester'; + +import {noDynamicTranslations} from './no-dynamic-translations'; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-dynamic-translations', noDynamicTranslations, { + valid: [ + {code: "t('static string')"}, + {code: 't(`template without interpolation`)'}, + {code: "tn('%s item', '%s items', count)"}, + {code: "tct('Hello [name]', {name})"}, + ], + invalid: [ + { + code: 't(`Hello ${name}`)', + errors: [{messageId: 'interpolation'}], + }, + { + code: 't(dynamicVariable)', + errors: [{messageId: 'dynamic'}], + }, + ], +}); diff --git a/static/eslint/eslintPluginSentry/no-dynamic-translations.ts b/static/eslint/eslintPluginSentry/no-dynamic-translations.ts new file mode 100644 index 00000000000000..5e29d0d1c6d37c --- /dev/null +++ b/static/eslint/eslintPluginSentry/no-dynamic-translations.ts @@ -0,0 +1,57 @@ +import {AST_NODE_TYPES, ESLintUtils, type TSESTree} from '@typescript-eslint/utils'; + +const TRANSLATION_FNS = ['t', 'tn', 'tct']; + +export const noDynamicTranslations = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'problem', + docs: { + description: 'Disallow non-literal strings in t(), tn(), and tct()', + }, + schema: [], + messages: { + interpolation: + 'Dynamic value interpolation cannot be used in translation functions. Use a parameterized string literal instead.', + dynamic: + '{{fnName}}() cannot be used to translate dynamic values. Use a parameterized string literal instead.', + }, + }, + create(context) { + return { + CallExpression(node) { + if ( + node.callee.type !== AST_NODE_TYPES.Identifier || + !TRANSLATION_FNS.includes(node.callee.name) + ) { + return; + } + + if (node.arguments.length === 0) { + return; + } + + const fnName = node.callee.name; + + function checkTranslationArg(arg: TSESTree.CallExpressionArgument) { + if (arg.type === AST_NODE_TYPES.TemplateLiteral) { + if (arg.expressions.length === 0) { + return; + } + context.report({node: arg, messageId: 'interpolation'}); + return; + } + + if (arg.type !== AST_NODE_TYPES.Literal) { + context.report({node: arg, messageId: 'dynamic', data: {fnName}}); + } + } + + checkTranslationArg(node.arguments[0]!); + + if (fnName === 'tn' && node.arguments.length > 1) { + checkTranslationArg(node.arguments[1]!); + } + }, + }; + }, +}); diff --git a/static/eslint/eslintPluginSentry/no-styled-shortcut.spec.ts b/static/eslint/eslintPluginSentry/no-styled-shortcut.spec.ts new file mode 100644 index 00000000000000..1bc2c116d17e06 --- /dev/null +++ b/static/eslint/eslintPluginSentry/no-styled-shortcut.spec.ts @@ -0,0 +1,16 @@ +import {RuleTester} from '@typescript-eslint/rule-tester'; + +import {noStyledShortcut} from './no-styled-shortcut'; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-styled-shortcut', noStyledShortcut, { + valid: [{code: "var Test = styled('div')``;"}], + invalid: [ + { + code: 'var Test = styled.div``;', + output: "var Test = styled('div')``;", + errors: [{messageId: 'noShorthand'}], + }, + ], +}); diff --git a/static/eslint/eslintPluginSentry/no-styled-shortcut.ts b/static/eslint/eslintPluginSentry/no-styled-shortcut.ts new file mode 100644 index 00000000000000..98fc495316e643 --- /dev/null +++ b/static/eslint/eslintPluginSentry/no-styled-shortcut.ts @@ -0,0 +1,43 @@ +import {AST_NODE_TYPES, ESLintUtils} from '@typescript-eslint/utils'; + +export const noStyledShortcut = ESLintUtils.RuleCreator.withoutDocs({ + meta: { + type: 'suggestion', + docs: { + description: + "Disallow styled-components shorthand (e.g. styled.div) — use styled('div') instead", + }, + fixable: 'code', + schema: [], + messages: { + noShorthand: + 'Do not use the shorthand/member expression style of styled. Use the function call syntax instead: styled({{element}}).', + }, + }, + create(context) { + return { + TaggedTemplateExpression(node) { + const {tag} = node; + if ( + tag.type !== AST_NODE_TYPES.MemberExpression || + tag.object.type !== AST_NODE_TYPES.Identifier || + tag.object.name !== 'styled' || + tag.property.type !== AST_NODE_TYPES.Identifier + ) { + return; + } + + const element = tag.property.name; + + context.report({ + node, + messageId: 'noShorthand', + data: {element}, + fix(fixer) { + return fixer.replaceText(tag, `styled('${element}')`); + }, + }); + }, + }; + }, +}); From 54c9b67efa3922671f0cc4506848a7a955dd0d8b Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Thu, 2 Apr 2026 10:06:33 -0700 Subject: [PATCH 15/80] chore(ACI): Update front end usage of detector create endpoint to project scoped one (#111933) In https://github.com/getsentry/sentry/pull/110285 we created a project scoped version of the `OrganizationDetectorIndex` `POST` method and this PR updates the front end usage of it so we can move towards removing it. --- static/app/views/detectors/hooks/index.ts | 9 ++-- .../app/views/detectors/new-setting.spec.tsx | 52 +++++++++---------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/static/app/views/detectors/hooks/index.ts b/static/app/views/detectors/hooks/index.ts index 5863f6ff33b40a..822aca9c0630d5 100644 --- a/static/app/views/detectors/hooks/index.ts +++ b/static/app/views/detectors/hooks/index.ts @@ -111,9 +111,12 @@ export function useCreateDetector() { return useMutation({ mutationFn: data => api.requestPromise( - getApiUrl('/organizations/$organizationIdOrSlug/detectors/', { - path: {organizationIdOrSlug: org.slug}, - }), + getApiUrl( + '/organizations/$organizationIdOrSlug/projects/$projectIdOrSlug/detectors/', + { + path: {organizationIdOrSlug: org.slug, projectIdOrSlug: data.projectId}, + } + ), { method: 'POST', data, diff --git a/static/app/views/detectors/new-setting.spec.tsx b/static/app/views/detectors/new-setting.spec.tsx index 33b0d00203edfd..434f16d58d952c 100644 --- a/static/app/views/detectors/new-setting.spec.tsx +++ b/static/app/views/detectors/new-setting.spec.tsx @@ -167,7 +167,7 @@ describe('DetectorEdit', () => { it('can submit a new metric detector', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '123'}), }); @@ -190,7 +190,7 @@ describe('DetectorEdit', () => { await waitFor(() => { expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ name: 'Foo', @@ -242,7 +242,7 @@ describe('DetectorEdit', () => { it('prefills form when selecting a template', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '123'}), }); @@ -274,7 +274,7 @@ describe('DetectorEdit', () => { await waitFor(() => { expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ type: 'metric_issue', @@ -297,7 +297,7 @@ describe('DetectorEdit', () => { it('prefills from URL query params and submits', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '123'}), }); @@ -331,7 +331,7 @@ describe('DetectorEdit', () => { await waitFor(() => { expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ name: 'Users experiencing errors above 100 over past 1 hour', @@ -374,7 +374,7 @@ describe('DetectorEdit', () => { it('can submit a new metric detector with event.type:error', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '123'}), }); @@ -410,7 +410,7 @@ describe('DetectorEdit', () => { await waitFor(() => { expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ conditionGroup: { @@ -447,7 +447,7 @@ describe('DetectorEdit', () => { it('submits manual resolution threshold when selected', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '321'}), }); @@ -477,7 +477,7 @@ describe('DetectorEdit', () => { }); expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ type: 'metric_issue', @@ -505,7 +505,7 @@ describe('DetectorEdit', () => { it('uses medium threshold for default resolution when both high and medium are set', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '789'}), }); @@ -535,7 +535,7 @@ describe('DetectorEdit', () => { }); expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ type: 'metric_issue', @@ -597,7 +597,7 @@ describe('DetectorEdit', () => { it('creates detector with dynamic detection and no resolution thresholds', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '456'}), }); @@ -630,7 +630,7 @@ describe('DetectorEdit', () => { await waitFor(() => { expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ name: 'Dynamic', @@ -675,7 +675,7 @@ describe('DetectorEdit', () => { it('can submit a new metric detector with apdex aggregate', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '789'}), }); @@ -711,7 +711,7 @@ describe('DetectorEdit', () => { await waitFor(() => { expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ name: 'Apdex', @@ -803,7 +803,7 @@ describe('DetectorEdit', () => { }); const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${metricsOrganization.slug}/detectors/`, + url: `/organizations/${metricsOrganization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: MetricDetectorFixture({id: '999'}), }); @@ -836,7 +836,7 @@ describe('DetectorEdit', () => { await waitFor(() => { expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${metricsOrganization.slug}/detectors/`, + `/organizations/${metricsOrganization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ name: 'Metrics Alert', @@ -894,7 +894,7 @@ describe('DetectorEdit', () => { it('shows detect and resolve fields and submits default thresholds', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: UptimeDetectorFixture(), }); @@ -943,7 +943,7 @@ describe('DetectorEdit', () => { }); expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ config: { @@ -994,7 +994,7 @@ describe('DetectorEdit', () => { it('submits custom thresholds when changed', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: UptimeDetectorFixture(), }); @@ -1030,7 +1030,7 @@ describe('DetectorEdit', () => { }); expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ config: { @@ -1134,7 +1134,7 @@ describe('DetectorEdit', () => { it('submits default cron config with no changes', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: CronDetectorFixture({id: '999'}), }); @@ -1151,7 +1151,7 @@ describe('DetectorEdit', () => { }); expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ type: 'monitor_check_in_failure', @@ -1179,7 +1179,7 @@ describe('DetectorEdit', () => { it('submits crons config with changes', async () => { const mockCreateDetector = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/detectors/`, + url: `/organizations/${organization.slug}/projects/${project.id}/detectors/`, method: 'POST', body: CronDetectorFixture({id: '999'}), }); @@ -1199,7 +1199,7 @@ describe('DetectorEdit', () => { }); expect(mockCreateDetector).toHaveBeenCalledWith( - `/organizations/${organization.slug}/detectors/`, + `/organizations/${organization.slug}/projects/${project.id}/detectors/`, expect.objectContaining({ data: expect.objectContaining({ type: 'monitor_check_in_failure', From 78f6d108e33ff4ae089cae948ec402d17aaba62a Mon Sep 17 00:00:00 2001 From: Brendan Hy Date: Thu, 2 Apr 2026 10:10:34 -0700 Subject: [PATCH 16/80] feat(billing): Select single project usage CSV (#112044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit depends on https://github.com/getsentry/getsentry/pull/19727 Replaces Download Project Breakdown button with dropdown to select single project Screenshot 2026-04-01 at 2 05 23 PM --- .../subscriptionPage/usageHistory.spec.tsx | 28 +++++++++++++++++++ .../views/subscriptionPage/usageHistory.tsx | 27 +++++++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx index d61ce2ed9d3f5b..a607562cd9d43b 100644 --- a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx @@ -1,4 +1,5 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; import {BillingConfigFixture} from 'getsentry-test/fixtures/billingConfig'; import {BillingHistoryFixture} from 'getsentry-test/fixtures/billingHistory'; @@ -9,6 +10,7 @@ import { } from 'getsentry-test/fixtures/subscription'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; import {DataCategory} from 'sentry/types/core'; import {PreviewDataFixture} from 'getsentry/__fixtures__/previewData'; @@ -851,6 +853,32 @@ describe('Subscription > UsageHistory', () => { expect(screen.queryByText('>100%')).not.toBeInTheDocument(); }); + it('opens the per-project CSV URL for the selected project', async () => { + window.open = jest.fn(); + const project = ProjectFixture({slug: 'my-project'}); + ProjectsStore.loadInitialData([project]); + + MockApiClient.addMockResponse({ + url: `/customers/${organization.slug}/history/`, + method: 'GET', + body: [BillingHistoryFixture()], + }); + const subscription = SubscriptionFixture({organization}); + SubscriptionStore.set(organization.slug, subscription); + + render(, {organization}); + + await userEvent.click( + await screen.findByRole('button', {name: /Download Project Breakdown/i}) + ); + await userEvent.click(screen.getByRole('option', {name: 'my-project'})); + + expect(window.open).toHaveBeenCalledWith( + 'https://sentry.io/organizations/acme/billing/history/625529670/export/per-project/my-project/', + '_blank' + ); + }); + it('shows >100% for UI profile duration overage', async () => { const MILLISECONDS_IN_HOUR = 60 * 60 * 1000; MockApiClient.addMockResponse({ diff --git a/static/gsApp/views/subscriptionPage/usageHistory.tsx b/static/gsApp/views/subscriptionPage/usageHistory.tsx index 3824ee9fdfb75d..634c7db1e4708f 100644 --- a/static/gsApp/views/subscriptionPage/usageHistory.tsx +++ b/static/gsApp/views/subscriptionPage/usageHistory.tsx @@ -4,7 +4,9 @@ import moment from 'moment-timezone'; import {Badge} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; +import {CompactSelect, type SelectOption} from '@sentry/scraps/compactSelect'; import {Container, Flex, Grid} from '@sentry/scraps/layout'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Text} from '@sentry/scraps/text'; import {LoadingError} from 'sentry/components/loadingError'; @@ -20,6 +22,7 @@ import {formatPercentage} from 'sentry/utils/number/formatPercentage'; import {useApiQuery} from 'sentry/utils/queryClient'; import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {useProjects} from 'sentry/utils/useProjects'; import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; import {withSubscription} from 'getsentry/components/withSubscription'; @@ -141,6 +144,7 @@ type RowProps = { function UsageHistoryRow({history}: RowProps) { const organization = useOrganization(); const [expanded, setExpanded] = useState(history.isCurrent); + const {projects, onSearch: onProjectSearch} = useProjects(); function renderOnDemandUsage({ sortedCategories, @@ -265,18 +269,27 @@ function UsageHistoryRow({history}: RowProps) { > {t('Download Summary')} - + /> {expanded && ( From c439c8409bfac4c9861c62ca1dee6d341300359f Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Thu, 2 Apr 2026 10:17:27 -0700 Subject: [PATCH 17/80] chore(ACI): Combine issue alert rule flags into one (#112072) Replace usage of multiple issue alert rule backwards compatible endpoint flags with a single one. Don't merge until https://github.com/getsentry/sentry-options-automator/pull/7070 is merged. --- src/sentry/api/endpoints/project_rule_details.py | 2 +- src/sentry/api/endpoints/project_rules.py | 2 +- .../rules/history/endpoints/project_rule_group_history.py | 2 +- src/sentry/rules/history/endpoints/project_rule_stats.py | 2 +- src/sentry/workflow_engine/docs/legacy_backport.md | 2 +- tests/sentry/api/endpoints/test_project_rule_details.py | 4 ++-- tests/sentry/api/endpoints/test_project_rules.py | 2 +- tests/sentry/rules/history/backends/test_postgres.py | 8 ++++---- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/sentry/api/endpoints/project_rule_details.py b/src/sentry/api/endpoints/project_rule_details.py index 4209086556956f..af65a749ce74b9 100644 --- a/src/sentry/api/endpoints/project_rule_details.py +++ b/src/sentry/api/endpoints/project_rule_details.py @@ -104,7 +104,7 @@ class ProjectRuleDetailsPutSerializer(serializers.Serializer): @cell_silo_endpoint class ProjectRuleDetailsEndpoint(WorkflowEngineRuleEndpoint): workflow_engine_method_flags = { - "GET": "organizations:workflow-engine-projectruledetailsendpoint-get", + "GET": "organizations:workflow-engine-issue-alert-endpoints-get", } publish_status = { "DELETE": ApiPublishStatus.PUBLIC, diff --git a/src/sentry/api/endpoints/project_rules.py b/src/sentry/api/endpoints/project_rules.py index 7b882f5be412ae..67b3e40d29f395 100644 --- a/src/sentry/api/endpoints/project_rules.py +++ b/src/sentry/api/endpoints/project_rules.py @@ -864,7 +864,7 @@ def get(self, request: Request, project: Project) -> Response: queryset: BaseQuerySet[Workflow, Workflow] | BaseQuerySet[Rule, Rule] serializer: WorkflowEngineRuleSerializer | RuleSerializer if features.has( - "organizations:workflow-engine-projectrulesendpoint-get", project.organization + "organizations:workflow-engine-issue-alert-endpoints-get", project.organization ) or features.has("organizations:workflow-engine-rule-serializers", project.organization): queryset = Workflow.objects.filter( detectorworkflow__detector__project=project, diff --git a/src/sentry/rules/history/endpoints/project_rule_group_history.py b/src/sentry/rules/history/endpoints/project_rule_group_history.py index 800fa7a196a326..a40b271575b0e2 100644 --- a/src/sentry/rules/history/endpoints/project_rule_group_history.py +++ b/src/sentry/rules/history/endpoints/project_rule_group_history.py @@ -58,7 +58,7 @@ def serialize( @cell_silo_endpoint class ProjectRuleGroupHistoryIndexEndpoint(WorkflowEngineRuleEndpoint): workflow_engine_method_flags = { - "GET": "organizations:workflow-engine-projectrulegroupstats-get", + "GET": "organizations:workflow-engine-issue-alert-endpoints-get", } publish_status = { "GET": ApiPublishStatus.EXPERIMENTAL, diff --git a/src/sentry/rules/history/endpoints/project_rule_stats.py b/src/sentry/rules/history/endpoints/project_rule_stats.py index 3738dee4ab8042..9fc28a081e3ecb 100644 --- a/src/sentry/rules/history/endpoints/project_rule_stats.py +++ b/src/sentry/rules/history/endpoints/project_rule_stats.py @@ -41,7 +41,7 @@ def serialize( @cell_silo_endpoint class ProjectRuleStatsIndexEndpoint(WorkflowEngineRuleEndpoint): workflow_engine_method_flags = { - "GET": "organizations:workflow-engine-projectrulegroupstats-get", + "GET": "organizations:workflow-engine-issue-alert-endpoints-get", } publish_status = { "GET": ApiPublishStatus.EXPERIMENTAL, diff --git a/src/sentry/workflow_engine/docs/legacy_backport.md b/src/sentry/workflow_engine/docs/legacy_backport.md index 155694e0ce6421..35a982fd2f82e7 100644 --- a/src/sentry/workflow_engine/docs/legacy_backport.md +++ b/src/sentry/workflow_engine/docs/legacy_backport.md @@ -64,7 +64,7 @@ All endpoints decorated with `@track_alert_endpoint_execution` are in scope for ## Feature Flag Strategy -`organizations:workflow-engine-rule-serializers` enables all backported paths at once (useful for testing). Per-endpoint flags (e.g. `organizations:workflow-engine-projectrulesendpoint-get`) allow independent prod rollout — each is OR'd with the broad flag. Naming convention: `organizations:workflow-engine-{lowercaseendpointclass}-{method}`. +`organizations:workflow-engine-rule-serializers` enables all backported paths at once (useful for testing). Per-endpoint flags (e.g. `organizations:workflow-engine-combinedruleindex-get`) allow independent prod rollout — each is OR'd with the broad flag. Per-feature flags (e.g. `organizations:workflow-engine-issue-alert-endpoints-get`) allow a subset of endpoints scoped to a feature to be enabled to avoid a large number of individual flags. Naming convention: `organizations:workflow-engine-{lowercaseendpointclass}-{method}`. ## Unsupported legacy features diff --git a/tests/sentry/api/endpoints/test_project_rule_details.py b/tests/sentry/api/endpoints/test_project_rule_details.py index 26cfc44c442955..3702b6ea4954b2 100644 --- a/tests/sentry/api/endpoints/test_project_rule_details.py +++ b/tests/sentry/api/endpoints/test_project_rule_details.py @@ -248,7 +248,7 @@ def test_workflow_engine_serializer_single_written_rule(self) -> None: assert response.data["conditions"][0]["name"] assert response.data["filters"][0]["name"] - @with_feature("organizations:workflow-engine-projectruledetailsendpoint-get") + @with_feature("organizations:workflow-engine-issue-alert-endpoints-get") def test_workflow_engine_granular_flag_dual_written_rule(self) -> None: response = self.get_success_response( self.organization.slug, self.project.slug, self.rule.id, status_code=200 @@ -257,7 +257,7 @@ def test_workflow_engine_granular_flag_dual_written_rule(self) -> None: assert response.data["environment"] is None assert response.data["conditions"][0]["name"] - @with_feature("organizations:workflow-engine-projectruledetailsendpoint-get") + @with_feature("organizations:workflow-engine-issue-alert-endpoints-get") def test_workflow_engine_granular_flag_single_written_rule(self) -> None: response = self.get_success_response( self.organization.slug, self.project.slug, self.fake_workflow_id, status_code=200 diff --git a/tests/sentry/api/endpoints/test_project_rules.py b/tests/sentry/api/endpoints/test_project_rules.py index eecf75bb295d17..3a319a7679da44 100644 --- a/tests/sentry/api/endpoints/test_project_rules.py +++ b/tests/sentry/api/endpoints/test_project_rules.py @@ -170,7 +170,7 @@ def test_workflow_engine(self) -> None: assert workflow_resp_2["id"] == str(get_fake_id_from_object_id(self.workflow.id)) assert workflow_resp_1["id"] == str(self.rule.id) - @with_feature("organizations:workflow-engine-projectrulesendpoint-get") + @with_feature("organizations:workflow-engine-issue-alert-endpoints-get") def test_workflow_engine_granular_flag(self) -> None: response = self.get_success_response( self.organization.slug, diff --git a/tests/sentry/rules/history/backends/test_postgres.py b/tests/sentry/rules/history/backends/test_postgres.py index 173b1343672c06..a6dd30e4cd49b8 100644 --- a/tests/sentry/rules/history/backends/test_postgres.py +++ b/tests/sentry/rules/history/backends/test_postgres.py @@ -197,7 +197,7 @@ def test_event_id(self) -> None: ) @with_feature("organizations:workflow-engine-rule-serializers") - @with_feature("organizations:workflow-engine-projectrulegroupstats-get") + @with_feature("organizations:workflow-engine-issue-alert-endpoints-get") def test_single_written_workflow_history(self) -> None: """Test using WorkflowFireHistory when feature flag is enabled""" workflow_triggers = self.create_data_condition_group() @@ -277,7 +277,7 @@ def test_single_written_workflow_history(self) -> None: ) @with_feature("organizations:workflow-engine-rule-serializers") - @with_feature("organizations:workflow-engine-projectrulegroupstats-get") + @with_feature("organizations:workflow-engine-issue-alert-endpoints-get") def test_combined_rule_and_workflow_history(self) -> None: """Test combining RuleFireHistory and WorkflowFireHistory when both exist""" rule = self.create_project_rule(project=self.event.project) @@ -418,7 +418,7 @@ def test(self) -> None: assert [r.count for r in results[-5:]] == [0, 0, 1, 1, 0] @with_feature("organizations:workflow-engine-rule-serializers") - @with_feature("organizations:workflow-engine-projectrulegroupstats-get") + @with_feature("organizations:workflow-engine-issue-alert-endpoints-get") def test_single_written_workflow_history(self) -> None: """Test using WorkflowFireHistory when feature flag is enabled""" workflow_triggers = self.create_data_condition_group() @@ -455,7 +455,7 @@ def test_single_written_workflow_history(self) -> None: assert [r.count for r in results[-5:]] == [0, 2, 0, 1, 0] @with_feature("organizations:workflow-engine-rule-serializers") - @with_feature("organizations:workflow-engine-projectrulegroupstats-get") + @with_feature("organizations:workflow-engine-issue-alert-endpoints-get") def test_combined_rule_and_workflow_history(self) -> None: """Test combining RuleFireHistory and WorkflowFireHistory when both exist""" rule = self.create_project_rule(project=self.event.project) From 169b70a402e4bf005824a35931e567fccde9732b Mon Sep 17 00:00:00 2001 From: Lyn Nagara <1779792+lynnagara@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:17:51 -0700 Subject: [PATCH 18/80] ref(cells): Switch to SENTRY_LOCAL_CELL and SENTRY_CELLS (#111932) the old and new names are currently both set to the same value SENTRY_REGION and SENTRY_REGION_CONFIG will be removed as a follow up --- src/sentry/api/utils.py | 6 +++--- src/sentry/conf/server.py | 7 +++++-- src/sentry/core/endpoints/organization_index.py | 2 +- .../commands/presenters/webhookpresenter.py | 4 ++-- src/sentry/runner/initializer.py | 4 ++-- src/sentry/silo/client.py | 2 +- src/sentry/testutils/cell.py | 17 ++++++++++++----- src/sentry/testutils/factories.py | 2 +- src/sentry/testutils/pytest/sentry.py | 5 ++++- src/sentry/testutils/silo.py | 2 +- src/sentry/types/cell.py | 8 ++++---- src/sentry/utils/sdk.py | 4 ++-- .../test_accept_organization_invite.py | 2 +- .../core/endpoints/test_organization_index.py | 2 +- tests/sentry/models/test_projectkey.py | 4 ++-- .../test_project_preprod_upload_options.py | 2 +- tests/sentry/relocation/tasks/test_process.py | 2 +- .../presenters/test_webhookpresenter.py | 8 ++++---- tests/sentry/types/test_cell.py | 8 ++++---- tests/sentry/web/test_api.py | 2 +- tests/sentry/web/test_client_config.py | 10 +++++----- 21 files changed, 58 insertions(+), 45 deletions(-) diff --git a/src/sentry/api/utils.py b/src/sentry/api/utils.py index 38bdc91993be74..9e78e5a551a77d 100644 --- a/src/sentry/api/utils.py +++ b/src/sentry/api/utils.py @@ -310,7 +310,7 @@ def generate_locality_url(locality_name: str | None = None) -> str: If locality_name is not provided, it is inferred from the running silo: in CELL mode the local cell's locality is used; in MONOLITH mode with - SENTRY_REGION set the corresponding locality is resolved from that cell name. + SENTRY_LOCAL_CELL set the corresponding locality is resolved from that cell name. Falls back to system.url-prefix when no template or locality name is available. """ region_url_template: str | None = options.get("system.region-api-url-template") @@ -320,9 +320,9 @@ def generate_locality_url(locality_name: str | None = None) -> str: if ( locality_name is None and SiloMode.get_current_mode() == SiloMode.MONOLITH - and settings.SENTRY_REGION + and settings.SENTRY_LOCAL_CELL ): - locality_name = get_locality_name_for_cell(settings.SENTRY_REGION) + locality_name = get_locality_name_for_cell(settings.SENTRY_LOCAL_CELL) if not region_url_template or not locality_name: return options.get("system.url-prefix") return region_url_template.replace("{region}", locality_name) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 2f8c70b2005b52..ef79ada6cf5c8d 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -3262,7 +3262,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: # Addresses are hardcoded based on the defaults # we use in commands/devserver. region_port = os.environ.get("SENTRY_REGION_SILO_PORT", "8010") - SENTRY_REGION_CONFIG = [ + SENTRY_CELLS = [ { "name": "us", "snowflake_id": 1, @@ -3270,7 +3270,10 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "address": f"http://127.0.0.1:{region_port}", } ] - SENTRY_MONOLITH_REGION = SENTRY_REGION_CONFIG[0]["name"] + SENTRY_MONOLITH_REGION = SENTRY_CELLS[0]["name"] + + # TODO(cells): remove after getsentry updated + SENTRY_REGION_CONFIG = SENTRY_CELLS # Cross region RPC authentication RPC_SHARED_SECRET = [ diff --git a/src/sentry/core/endpoints/organization_index.py b/src/sentry/core/endpoints/organization_index.py index 542e80e59ed549..183ea3e85d45cd 100644 --- a/src/sentry/core/endpoints/organization_index.py +++ b/src/sentry/core/endpoints/organization_index.py @@ -269,7 +269,7 @@ def post(self, request: Request) -> Response: ) rpc_org = organization_provisioning_service.provision_organization_in_cell( - cell_name=settings.SENTRY_REGION or settings.SENTRY_MONOLITH_REGION, + cell_name=settings.SENTRY_LOCAL_CELL or settings.SENTRY_MONOLITH_REGION, provisioning_options=provision_args, ) org = Organization.objects.get(id=rpc_org.id) diff --git a/src/sentry/runner/commands/presenters/webhookpresenter.py b/src/sentry/runner/commands/presenters/webhookpresenter.py index ec861b8cbdc047..f32bc99c14bd3a 100644 --- a/src/sentry/runner/commands/presenters/webhookpresenter.py +++ b/src/sentry/runner/commands/presenters/webhookpresenter.py @@ -53,8 +53,8 @@ def flush(self) -> None: return region: str | None = ( - settings.SENTRY_REGION - if settings.SENTRY_REGION + settings.SENTRY_LOCAL_CELL + if settings.SENTRY_LOCAL_CELL else settings.CUSTOMER_ID if settings.CUSTOMER_ID else settings.SILO_MODE diff --git a/src/sentry/runner/initializer.py b/src/sentry/runner/initializer.py index 96860193a710af..1e90cfc5665f6c 100644 --- a/src/sentry/runner/initializer.py +++ b/src/sentry/runner/initializer.py @@ -439,10 +439,10 @@ def validate_options(settings: Any) -> None: def validate_regions(settings: Any) -> None: from sentry.types.cell import load_from_config - if not settings.SENTRY_REGION_CONFIG: + if not settings.SENTRY_CELLS: return - load_from_config(settings.SENTRY_REGION_CONFIG, settings.SENTRY_LOCALITIES).validate_all() + load_from_config(settings.SENTRY_CELLS, settings.SENTRY_LOCALITIES).validate_all() def monkeypatch_django_migrations() -> None: diff --git a/src/sentry/silo/client.py b/src/sentry/silo/client.py index ca723f57e99679..cbb71092f6605e 100644 --- a/src/sentry/silo/client.py +++ b/src/sentry/silo/client.py @@ -44,7 +44,7 @@ class SiloClientError(Exception): def get_cell_ip_addresses() -> frozenset[ipaddress.IPv4Address | ipaddress.IPv6Address]: """ - Infers the Cell Silo IP addresses from the SENTRY_REGION_CONFIG setting. + Infers the Cell Silo IP addresses from the SENTRY_CELLS setting. """ cell_ip_addresses: set[ipaddress.IPv4Address | ipaddress.IPv6Address] = set() diff --git a/src/sentry/testutils/cell.py b/src/sentry/testutils/cell.py index 7594b2b4edb01a..1000e20161d8be 100644 --- a/src/sentry/testutils/cell.py +++ b/src/sentry/testutils/cell.py @@ -53,7 +53,10 @@ def swap_state( monolith_cell = cells[0] with override_settings(SENTRY_MONOLITH_REGION=monolith_cell.name): if local_cell: - with override_settings(SENTRY_REGION=local_cell.name): + # TODO(cells): Remove SENTRY_REGION once all references in getsentry tests updated + with override_settings( + SENTRY_LOCAL_CELL=local_cell.name, SENTRY_REGION=local_cell.name + ): yield else: yield @@ -70,7 +73,10 @@ def swap_state( @contextmanager def swap_to_default_cell(self) -> Generator[None]: """Swap to the monolith cell when entering cell mode.""" - with override_settings(SENTRY_REGION=self._default_cell.name): + # TODO(cells): Remove SENTRY_REGION once all references in getsentry tests updated + with override_settings( + SENTRY_LOCAL_CELL=self._default_cell.name, SENTRY_REGION=self._default_cell.name + ): yield @contextmanager @@ -79,7 +85,8 @@ def swap_to_cell_by_name(self, cell_name: str) -> Generator[None]: cell = self.get_cell_by_name(cell_name) if cell is None: raise Exception("specified swap cell not found") - with override_settings(SENTRY_REGION=cell.name): + # TODO(cells): Remove SENTRY_REGION once all references in getsentry tests updated + with override_settings(SENTRY_LOCAL_CELL=cell.name, SENTRY_REGION=cell.name): yield @@ -93,9 +100,9 @@ def get_test_env_directory() -> TestEnvCellDirectory: def override_cells(cells: Sequence[Cell], local_cell: Cell | None = None) -> Generator[None]: """Override the global set of existing cells. - The overriding value takes the place of the `SENTRY_REGION_CONFIG` setting and + The overriding value takes the place of the `SENTRY_CELLS` setting and changes the behavior of the module-level functions in `sentry.types.cell`. This - is preferable to overriding the `SENTRY_REGION_CONFIG` setting value directly + is preferable to overriding the `SENTRY_CELLS` setting value directly because the cell mapping may already be cached. """ with get_test_env_directory().swap_state(cells, local_cell=local_cell): diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index cb3cd02f25d466..fe4ba6414a5786 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -393,7 +393,7 @@ def create_organization(name=None, owner=None, cell: Cell | str | None = None, * cell_name = cell_obj.name ctx.enter_context( - override_settings(SILO_MODE=SiloMode.CELL, SENTRY_REGION=cell_name) + override_settings(SILO_MODE=SiloMode.CELL, SENTRY_LOCAL_CELL=cell_name) ) with outbox_context(flush=False): diff --git a/src/sentry/testutils/pytest/sentry.py b/src/sentry/testutils/pytest/sentry.py index b98ff776d41a5a..4ee03479684b0b 100644 --- a/src/sentry/testutils/pytest/sentry.py +++ b/src/sentry/testutils/pytest/sentry.py @@ -85,9 +85,12 @@ def _configure_test_env_cells() -> None: RegionCategory.MULTI_TENANT, ) - settings.SENTRY_REGION = cell_name + settings.SENTRY_LOCAL_CELL = cell_name settings.SENTRY_MONOLITH_REGION = cell_name + # TODO(cells): Remove once all references in getsentry tests updated + settings.SENTRY_REGION = cell_name + # This not only populates the environment with the default cell, but also # ensures that a TestEnvCellDirectory instance is injected into global state. # See sentry.testutils.cell.get_test_env_directory, which relies on it. diff --git a/src/sentry/testutils/silo.py b/src/sentry/testutils/silo.py index 74a79df4be2bd2..37778335eb1547 100644 --- a/src/sentry/testutils/silo.py +++ b/src/sentry/testutils/silo.py @@ -378,7 +378,7 @@ def assume_test_silo_mode( with cell_dir.swap_to_cell_by_name(cell_name): yield else: - with override_settings(SENTRY_REGION=None): + with override_settings(SENTRY_LOCAL_CELL=None): yield diff --git a/src/sentry/types/cell.py b/src/sentry/types/cell.py index e8a56d605616ca..d01ae02a23cf2c 100644 --- a/src/sentry/types/cell.py +++ b/src/sentry/types/cell.py @@ -325,7 +325,7 @@ def get_global_directory() -> CellDirectory: # For now, assume that all cell configs can be taken in through Django # settings. We may investigate other ways of delivering those configs in # production. - _global_directory = load_from_config(settings.SENTRY_REGION_CONFIG, settings.SENTRY_LOCALITIES) + _global_directory = load_from_config(settings.SENTRY_CELLS, settings.SENTRY_LOCALITIES) return _global_directory @@ -433,12 +433,12 @@ def get_local_cell() -> Cell: if single_process_cell is not None: return single_process_cell - if not settings.SENTRY_REGION: + if not settings.SENTRY_LOCAL_CELL: if in_test_environment(): return get_cell_by_name(settings.SENTRY_MONOLITH_REGION) else: - raise Exception("SENTRY_REGION must be set when server is in REGION silo mode") - return get_cell_by_name(settings.SENTRY_REGION) + raise Exception("SENTRY_LOCAL_CELL must be set when server is in CELL silo mode") + return get_cell_by_name(settings.SENTRY_LOCAL_CELL) @control_silo_function diff --git a/src/sentry/utils/sdk.py b/src/sentry/utils/sdk.py index 04d7916bdcb0c9..823fe4e40e9070 100644 --- a/src/sentry/utils/sdk.py +++ b/src/sentry/utils/sdk.py @@ -252,8 +252,8 @@ def before_send(event: Event, hint: Hint) -> Event | None: if event.get("tags"): if settings.SILO_MODE: event["tags"]["silo_mode"] = str(settings.SILO_MODE) - if settings.SENTRY_REGION: - event["tags"]["sentry_region"] = settings.SENTRY_REGION + if settings.SENTRY_LOCAL_CELL: + event["tags"]["sentry_region"] = settings.SENTRY_LOCAL_CELL if hint.get("exc_info", [None])[0] == OperationalError: event["level"] = "warning" diff --git a/tests/sentry/api/endpoints/test_accept_organization_invite.py b/tests/sentry/api/endpoints/test_accept_organization_invite.py index 4efd86438e036f..2b25869a5d5595 100644 --- a/tests/sentry/api/endpoints/test_accept_organization_invite.py +++ b/tests/sentry/api/endpoints/test_accept_organization_invite.py @@ -30,7 +30,7 @@ class AcceptInviteTest(TestCase, HybridCloudTestMixin): def setUp(self) -> None: super().setUp() - with override_settings(SENTRY_REGION=settings.SENTRY_MONOLITH_REGION): + with override_settings(SENTRY_LOCAL_CELL=settings.SENTRY_MONOLITH_REGION): self.organization = self.create_organization(owner=self.create_user("foo@example.com")) self.user = self.create_user("bar@example.com") diff --git a/tests/sentry/core/endpoints/test_organization_index.py b/tests/sentry/core/endpoints/test_organization_index.py index 4087609ce21fb3..38f6a41a1955ae 100644 --- a/tests/sentry/core/endpoints/test_organization_index.py +++ b/tests/sentry/core/endpoints/test_organization_index.py @@ -362,7 +362,7 @@ def test_demo_user_handler_level_guard(self, mock_perm: MagicMock) -> None: class OrganizationsCreateInRegionTest(OrganizationIndexTest): method = "post" - @override_settings(SENTRY_MONOLITH_REGION="us", SENTRY_REGION="de") + @override_settings(SENTRY_MONOLITH_REGION="us", SENTRY_LOCAL_CELL="de") def test_success(self) -> None: data = {"name": "hello world", "slug": "slug-world"} response = self.get_success_response(**data) diff --git a/tests/sentry/models/test_projectkey.py b/tests/sentry/models/test_projectkey.py index 60a8d377984c12..5cf5fced910afd 100644 --- a/tests/sentry/models/test_projectkey.py +++ b/tests/sentry/models/test_projectkey.py @@ -134,7 +134,7 @@ def test_get_dsn_org_subdomain(self) -> None: == f"http://{host}/api/{self.project.id}/cron/___MONITOR_SLUG___/abc/" ) - @override_settings(SENTRY_REGION="us") + @override_settings(SENTRY_LOCAL_CELL="us") def test_get_dsn_multiregion(self) -> None: key = self.model(project_id=self.project.id, public_key="abc", secret_key="xyz") host = "us.testserver" if SiloMode.get_current_mode() == SiloMode.CELL else "testserver" @@ -151,7 +151,7 @@ def test_get_dsn_multiregion(self) -> None: == f"http://{host}/api/{self.project.id}/cron/___MONITOR_SLUG___/abc/" ) - @override_settings(SENTRY_REGION="us") + @override_settings(SENTRY_LOCAL_CELL="us") def test_get_dsn_org_subdomain_and_multiregion(self) -> None: with self.feature("organizations:org-ingest-subdomains"): key = self.model(project_id=self.project.id, public_key="abc", secret_key="xyz") diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py index 80c33f43bb5b51..9434f07c0b3f24 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_upload_options.py @@ -38,7 +38,7 @@ def test_objectstore_url_uses_region_endpoint(self) -> None: assert response.status_code == 200 url = response.data["objectstore"]["url"] - region = settings.SENTRY_REGION + region = settings.SENTRY_LOCAL_CELL assert url.startswith(f"https://{region}.testserver/") assert url.endswith(f"/api/0/organizations/{self.org.id}/objectstore") diff --git a/tests/sentry/relocation/tasks/test_process.py b/tests/sentry/relocation/tasks/test_process.py index 8fa4e5bd7a0d58..429d8b259b56b9 100644 --- a/tests/sentry/relocation/tasks/test_process.py +++ b/tests/sentry/relocation/tasks/test_process.py @@ -258,7 +258,7 @@ def setUp(self) -> None: self.uuid = str(self.relocation.uuid) @override_settings( - SENTRY_MONOLITH_REGION=REQUESTING_TEST_REGION, SENTRY_REGION=REQUESTING_TEST_REGION + SENTRY_MONOLITH_REGION=REQUESTING_TEST_REGION, SENTRY_LOCAL_CELL=REQUESTING_TEST_REGION ) @patch("sentry.relocation.tasks.process.cross_region_export_timeout_check.apply_async") def test_success_saas_to_saas( diff --git a/tests/sentry/runner/commands/presenters/test_webhookpresenter.py b/tests/sentry/runner/commands/presenters/test_webhookpresenter.py index c0d7cfa47c88af..4d726aeb3fcea6 100644 --- a/tests/sentry/runner/commands/presenters/test_webhookpresenter.py +++ b/tests/sentry/runner/commands/presenters/test_webhookpresenter.py @@ -11,7 +11,7 @@ @override_settings( OPTIONS_AUTOMATOR_SLACK_WEBHOOK_URL="https://test/", OPTIONS_AUTOMATOR_HMAC_SECRET="test-secret-key", - SENTRY_REGION="test_region", + SENTRY_LOCAL_CELL="test_region", ) def test_is_slack_enabled() -> None: responses.add(responses.POST, "https://test/", status=200) @@ -91,7 +91,7 @@ def test_is_slack_enabled() -> None: @responses.activate @override_settings( OPTIONS_AUTOMATOR_SLACK_WEBHOOK_URL="https://test/", - SENTRY_REGION="test_region", + SENTRY_LOCAL_CELL="test_region", ) def test_is_slack_enabled_without_secret_key() -> None: responses.add(responses.POST, "https://test/", status=200) @@ -169,7 +169,7 @@ def test_is_slack_enabled_without_secret_key() -> None: @override_settings( OPTIONS_AUTOMATOR_SLACK_WEBHOOK_URL="https://test/", OPTIONS_AUTOMATOR_HMAC_SECRET="test-secret-key", - SENTRY_REGION="test_region", + SENTRY_LOCAL_CELL="test_region", ) def test_slack_presenter_empty() -> None: presenter = WebhookPresenter("options-automator") @@ -184,7 +184,7 @@ def test_slack_presenter_empty() -> None: @override_settings( OPTIONS_AUTOMATOR_SLACK_WEBHOOK_URL="https://test/", OPTIONS_AUTOMATOR_HMAC_SECRET="test-secret-key", - SENTRY_REGION="test_region", + SENTRY_LOCAL_CELL="test_region", ) def test_slack_presenter_methods_with_different_types() -> None: responses.add(responses.POST, "https://test/", status=200) diff --git a/tests/sentry/types/test_cell.py b/tests/sentry/types/test_cell.py index c7f493bad13598..d00b5ad53a91c9 100644 --- a/tests/sentry/types/test_cell.py +++ b/tests/sentry/types/test_cell.py @@ -95,7 +95,7 @@ def test_cell_config_parsing_in_control(self) -> None: directory = load_from_config(self._INPUTS, []) assert directory.cells == frozenset(self._EXPECTED_OUTPUTS) - @override_settings(SILO_MODE=SiloMode.CELL, SENTRY_REGION="us") + @override_settings(SILO_MODE=SiloMode.CELL, SENTRY_LOCAL_CELL="us") def test_get_local_cell(self) -> None: with override_settings(SENTRY_MONOLITH_REGION="us"): directory = load_from_config(self._INPUTS, []) @@ -143,11 +143,11 @@ def test_validate_cell(self) -> None: def test_locality_to_url(self) -> None: locality = Locality("us", frozenset(["us"]), RegionCategory.MULTI_TENANT, new_org_cell="us") - with override_settings(SILO_MODE=SiloMode.CELL, SENTRY_REGION="us"): + with override_settings(SILO_MODE=SiloMode.CELL, SENTRY_LOCAL_CELL="us"): assert locality.to_url("/avatar/abcdef/") == "http://us.testserver/avatar/abcdef/" - with override_settings(SILO_MODE=SiloMode.CONTROL, SENTRY_REGION=""): + with override_settings(SILO_MODE=SiloMode.CONTROL, SENTRY_LOCAL_CELL=""): assert locality.to_url("/avatar/abcdef/") == "http://us.testserver/avatar/abcdef/" - with override_settings(SILO_MODE=SiloMode.MONOLITH, SENTRY_REGION=""): + with override_settings(SILO_MODE=SiloMode.MONOLITH, SENTRY_LOCAL_CELL=""): assert locality.to_url("/avatar/abcdef/") == "http://testserver/avatar/abcdef/" @patch("sentry.types.cell.sentry_sdk") diff --git a/tests/sentry/web/test_api.py b/tests/sentry/web/test_api.py index d42e35316cb463..4876bd2456d0f6 100644 --- a/tests/sentry/web/test_api.py +++ b/tests/sentry/web/test_api.py @@ -366,7 +366,7 @@ def test_organization_url_region(self) -> None: assert resp.status_code == 200 assert resp["Content-Type"] == "application/json" - with override_settings(SENTRY_REGION="eu"): + with override_settings(SENTRY_LOCAL_CELL="eu"): resp = self.client.get(self.path) assert resp.status_code == 200 assert resp["Content-Type"] == "application/json" diff --git a/tests/sentry/web/test_client_config.py b/tests/sentry/web/test_client_config.py index 7cbfe4326062f3..08b63c03ecc298 100644 --- a/tests/sentry/web/test_client_config.py +++ b/tests/sentry/web/test_client_config.py @@ -282,17 +282,17 @@ def test_client_config_links_regionurl() -> None: request, user = make_user_request_from_org() request.user = user - with override_settings(SILO_MODE=SiloMode.CELL, SENTRY_REGION="us"): + with override_settings(SILO_MODE=SiloMode.CELL, SENTRY_LOCAL_CELL="us"): result = get_client_config(request) assert result["links"] assert result["links"]["regionUrl"] == "http://us.testserver" - with override_settings(SILO_MODE=SiloMode.CONTROL, SENTRY_REGION=None): + with override_settings(SILO_MODE=SiloMode.CONTROL, SENTRY_LOCAL_CELL=None): result = get_client_config(request) assert result["links"] assert result["links"]["regionUrl"] == "http://us.testserver" - with override_settings(SILO_MODE=SiloMode.MONOLITH, SENTRY_REGION="eu"): + with override_settings(SILO_MODE=SiloMode.MONOLITH, SENTRY_LOCAL_CELL="eu"): result = get_client_config(request) assert result["links"] assert result["links"]["regionUrl"] == "http://eu.testserver" @@ -332,13 +332,13 @@ def test_client_config_links_with_priority_org() -> None: # we want the org context to have priority over the active org assert request.session["activeorg"] != org.slug - with override_settings(SILO_MODE=SiloMode.CELL, SENTRY_REGION="us"): + with override_settings(SILO_MODE=SiloMode.CELL, SENTRY_LOCAL_CELL="us"): result = get_client_config(request, org_context) assert result["links"] assert result["links"]["regionUrl"] == "http://us.testserver" assert result["links"]["organizationUrl"] == f"http://{org.slug}.testserver" - with override_settings(SILO_MODE=SiloMode.CONTROL, SENTRY_REGION=None): + with override_settings(SILO_MODE=SiloMode.CONTROL, SENTRY_LOCAL_CELL=None): result = get_client_config(request, org_context) assert result["links"] assert result["links"]["regionUrl"] == "http://us.testserver" From 20d65dfbfdc2e3634ceb0d25dafee24cb8b39e3a Mon Sep 17 00:00:00 2001 From: Josh Ferge Date: Thu, 2 Apr 2026 13:33:14 -0400 Subject: [PATCH 19/80] feat(seer): Add issue summary experimental flag (#112115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `organizations:issue-summary-experimental` feature flag (Flagpole-controlled) to run an A/B experiment on issue summary input - When enabled, the regular summary call is tagged as `"control"` and a second call is made to Seer with **breadcrumbs and trace removed**, tagged as `"experimental"` - The experimental call does not affect the persisted summary — only the control result is cached/returned - Seer-side changes in companion PR: https://github.com/getsentry/seer/pull/5611 ## Test plan - [x] Tests added for experiment flag calling Seer twice (control + experimental) - [x] Tests added for experiment flag off only making one call - [x] Tests added for experimental call failure not affecting regular flow --- src/sentry/features/temporary.py | 2 + src/sentry/seer/autofix/issue_summary.py | 22 +++ src/sentry/seer/signed_seer_api.py | 1 + .../sentry/seer/autofix/test_issue_summary.py | 151 +++++++++++++++++- 4 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 865c7346a27f27..235150677a15b5 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -344,6 +344,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:init-sentry-toolbar", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, default=False, api_expose=True) # Enable new stack trace component for issue details manager.add("organizations:issue-details-new-stack-trace", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Remove trace and breadcrumbs from issue summary input + manager.add("organizations:issue-summary-experimental", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable suspect feature tags endpoint. manager.add("organizations:issues-suspect-tags", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Lets organizations manage grouping configs diff --git a/src/sentry/seer/autofix/issue_summary.py b/src/sentry/seer/autofix/issue_summary.py index dac295292c1d7d..367131d81def82 100644 --- a/src/sentry/seer/autofix/issue_summary.py +++ b/src/sentry/seer/autofix/issue_summary.py @@ -283,6 +283,7 @@ def _call_seer( group: Group, serialized_event: dict[str, Any], trace_tree: dict[str, Any] | None, + experiment_variant: str | None = None, ): body = SummarizeIssueRequest( group_id=group.id, @@ -296,6 +297,7 @@ def _call_seer( organization_slug=group.organization.slug, organization_id=group.organization.id, project_id=group.project.id, + experiment_variant=experiment_variant, ) viewer_context = SeerViewerContext(organization_id=group.organization.id) response = make_summarize_issue_request(body, timeout=30, viewer_context=viewer_context) @@ -539,12 +541,32 @@ def _generate_summary( exc_info=True, ) + is_experiment = features.has("organizations:issue-summary-experimental", group.organization) + issue_summary = _call_seer( group, serialized_event, trace_tree, + experiment_variant="control" if is_experiment else None, ) + # Experiment: test summary quality without breadcrumbs and trace + if is_experiment: + try: + experimental_event = { + **serialized_event, + "entries": [ + e for e in serialized_event.get("entries", []) if e.get("type") != "breadcrumbs" + ], + } + _call_seer(group, experimental_event, None, experiment_variant="experimental") + except Exception: + logger.warning( + "Failed to generate experimental issue summary", + extra={"group_id": group.id}, + exc_info=True, + ) + summary_dict = issue_summary.dict() summary_dict["event_id"] = event.event_id cache.set(cache_key, summary_dict, timeout=int(timedelta(days=7).total_seconds())) diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index 10e6034f4392c9..20cf09976c871b 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -249,6 +249,7 @@ class SummarizeIssueRequest(TypedDict): organization_slug: str organization_id: int project_id: int + experiment_variant: NotRequired[str | None] class SupergroupsEmbeddingRequest(TypedDict): diff --git a/tests/sentry/seer/autofix/test_issue_summary.py b/tests/sentry/seer/autofix/test_issue_summary.py index 75aee98a200bc8..05ea097c4e69a7 100644 --- a/tests/sentry/seer/autofix/test_issue_summary.py +++ b/tests/sentry/seer/autofix/test_issue_summary.py @@ -122,7 +122,9 @@ def test_get_issue_summary_without_existing_summary( assert summary_data == convert_dict_key_case(expected_response_summary, snake_to_camel_case) mock_get_event.assert_called_once_with(self.group, self.user, provided_event_id=None) mock_get_trace_tree.assert_called_once() - mock_call_seer.assert_called_once_with(self.group, serialized_event, {"trace": "tree"}) + mock_call_seer.assert_called_once_with( + self.group, serialized_event, {"trace": "tree"}, experiment_variant=None + ) # Check if the cache was set correctly cached_summary = cache.get(f"ai-group-summary-v2:{self.group.id}") @@ -628,7 +630,9 @@ def test_get_issue_summary_handles_trace_tree_errors( summary_data, status_code = get_issue_summary(self.group, self.user) assert status_code == 200 - mock_call_seer.assert_called_once_with(self.group, serialized_event, None) + mock_call_seer.assert_called_once_with( + self.group, serialized_event, None, experiment_variant=None + ) @patch("sentry.seer.autofix.issue_summary.run_automation") @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") @@ -675,7 +679,9 @@ def test_get_issue_summary_with_should_run_automation_false( assert summary_data == convert_dict_key_case(expected_response_summary, snake_to_camel_case) mock_get_event.assert_called_once_with(self.group, self.user, provided_event_id=None) mock_get_trace_tree.assert_called_once() - mock_call_seer.assert_called_once_with(self.group, serialized_event, {"trace": "tree"}) + mock_call_seer.assert_called_once_with( + self.group, serialized_event, {"trace": "tree"}, experiment_variant=None + ) # Verify that run_automation was NOT called mock_run_automation.assert_not_called() @@ -684,6 +690,145 @@ def test_get_issue_summary_with_should_run_automation_false( cached_summary = cache.get(f"ai-group-summary-v2:{self.group.id}") assert cached_summary == expected_response_summary + @patch("sentry.seer.autofix.issue_summary.run_automation") + @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") + @patch("sentry.seer.autofix.issue_summary._call_seer") + @patch("sentry.seer.autofix.issue_summary._get_event") + def test_experiment_flag_calls_seer_twice( + self, + mock_get_event, + mock_call_seer, + mock_get_trace_tree, + mock_run_automation, + ): + """When the experiment flag is on, _call_seer is called twice: control and experimental.""" + event = Mock( + event_id="test_event_id", + data="test_event_data", + trace_id="test_trace", + datetime=datetime.datetime.now(), + ) + serialized_event = { + "event_id": "test_event_id", + "entries": [ + {"type": "exception", "data": {}}, + {"type": "breadcrumbs", "data": {"values": []}}, + ], + } + mock_get_event.return_value = [serialized_event, event] + mock_summary = SummarizeIssueResponse( + group_id=str(self.group.id), + headline="Test headline", + whats_wrong="Test whats wrong", + trace="Test trace", + possible_cause="Test possible cause", + ) + mock_call_seer.return_value = mock_summary + mock_get_trace_tree.return_value = {"trace": "tree"} + + with self.feature("organizations:issue-summary-experimental"): + get_issue_summary(self.group, self.user) + + assert mock_call_seer.call_count == 2 + + # First call: control with full data + control_call = mock_call_seer.call_args_list[0] + assert control_call == call( + self.group, serialized_event, {"trace": "tree"}, experiment_variant="control" + ) + + # Second call: experimental without breadcrumbs and without trace + experimental_call = mock_call_seer.call_args_list[1] + experimental_event = experimental_call[0][1] + assert all(e["type"] != "breadcrumbs" for e in experimental_event["entries"]) + assert experimental_call[0][2] is None # trace_tree is None + assert experimental_call[1]["experiment_variant"] == "experimental" + + @patch("sentry.seer.autofix.issue_summary.run_automation") + @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") + @patch("sentry.seer.autofix.issue_summary._call_seer") + @patch("sentry.seer.autofix.issue_summary._get_event") + def test_experiment_flag_off_calls_seer_once( + self, + mock_get_event, + mock_call_seer, + mock_get_trace_tree, + mock_run_automation, + ): + """When the experiment flag is off, only one call to _call_seer is made.""" + event = Mock( + event_id="test_event_id", + trace_id="test_trace", + datetime=datetime.datetime.now(), + ) + serialized_event = { + "event_id": "test_event_id", + "entries": [ + {"type": "exception", "data": {}}, + {"type": "breadcrumbs", "data": {"values": []}}, + ], + } + mock_get_event.return_value = [serialized_event, event] + mock_summary = SummarizeIssueResponse( + group_id=str(self.group.id), + headline="Test headline", + whats_wrong="Test whats wrong", + trace="Test trace", + possible_cause="Test possible cause", + ) + mock_call_seer.return_value = mock_summary + mock_get_trace_tree.return_value = {"trace": "tree"} + + get_issue_summary(self.group, self.user) + + mock_call_seer.assert_called_once_with( + self.group, serialized_event, {"trace": "tree"}, experiment_variant=None + ) + + @patch("sentry.seer.autofix.issue_summary.run_automation") + @patch("sentry.seer.autofix.issue_summary._get_trace_tree_for_event") + @patch("sentry.seer.autofix.issue_summary._call_seer") + @patch("sentry.seer.autofix.issue_summary._get_event") + def test_experiment_failure_does_not_affect_regular_flow( + self, + mock_get_event, + mock_call_seer, + mock_get_trace_tree, + mock_run_automation, + ): + """If the experimental call fails, the control result is still cached and returned.""" + event = Mock( + event_id="test_event_id", + trace_id="test_trace", + datetime=datetime.datetime.now(), + ) + serialized_event = { + "event_id": "test_event_id", + "entries": [{"type": "breadcrumbs", "data": {"values": []}}], + } + mock_get_event.return_value = [serialized_event, event] + mock_summary = SummarizeIssueResponse( + group_id=str(self.group.id), + headline="Test headline", + whats_wrong="Test whats wrong", + trace="Test trace", + possible_cause="Test possible cause", + ) + # First call (control) succeeds, second call (experimental) raises + mock_call_seer.side_effect = [mock_summary, Exception("experimental failed")] + mock_get_trace_tree.return_value = {"trace": "tree"} + + with self.feature("organizations:issue-summary-experimental"): + summary_data, status_code = get_issue_summary(self.group, self.user) + + assert status_code == 200 + assert summary_data["headline"] == "Test headline" + + # Cache should still be set from the control result + cached = cache.get(f"ai-group-summary-v2:{self.group.id}") + assert cached is not None + assert cached["headline"] == "Test headline" + class TestGetStoppingPointFromFixability: @pytest.mark.parametrize( From 04c8c9fd5e534d2827e7e1d20c4724eb1b7a1552 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:42:16 -0700 Subject: [PATCH 20/80] ref(assisted-query): add new analytics events segmented by area (#112029) --- .../askSeer/askSeerFeedback.tsx | 15 ++++--- .../askSeer/askSeerOption.tsx | 7 ++++ .../askSeerCombobox/askSeerComboBox.tsx | 41 +++++++++++++++++++ .../askSeerPollingComboBox.tsx | 41 +++++++++++++++++++ .../tokens/filter/filterKeyCombobox.tsx | 14 +++++++ .../filterKeyListBox/useFilterKeyListBox.tsx | 13 ++++++ .../utils/analytics/seerAnalyticsEvents.tsx | 41 +++++++++++++++++-- .../app/utils/analytics/tracingEventMap.tsx | 6 --- static/app/views/discover/index.tsx | 5 ++- .../results/issueListSeerComboBox.tsx | 8 ++++ static/app/views/explore/logs/content.tsx | 41 ++++++++++--------- .../explore/logs/logsTabSeerComboBox.tsx | 9 ++++ static/app/views/explore/metrics/content.tsx | 27 ++++++------ static/app/views/explore/spans/content.tsx | 31 +++++++------- .../explore/spans/spansTabSeerComboBox.tsx | 17 +++++++- static/app/views/issueList/index.tsx | 5 ++- .../views/issueList/issueListSeerComboBox.tsx | 16 +++++++- 17 files changed, 273 insertions(+), 64 deletions(-) diff --git a/static/app/components/searchQueryBuilder/askSeer/askSeerFeedback.tsx b/static/app/components/searchQueryBuilder/askSeer/askSeerFeedback.tsx index 9f0a9ffd45c696..0e2e82b9d5da9c 100644 --- a/static/app/components/searchQueryBuilder/askSeer/askSeerFeedback.tsx +++ b/static/app/components/searchQueryBuilder/askSeer/askSeerFeedback.tsx @@ -3,6 +3,7 @@ import {Fragment} from 'react'; import {Button} from '@sentry/scraps/button'; import {Text} from '@sentry/scraps/text'; +import {useAnalyticsArea} from 'sentry/components/analyticsArea'; import {AskSeerLabel} from 'sentry/components/searchQueryBuilder/askSeer/components'; import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; import {IconSeer, IconThumb} from 'sentry/icons'; @@ -12,15 +13,17 @@ import {useOrganization} from 'sentry/utils/useOrganization'; export function AskSeerFeedback() { const organization = useOrganization(); + const analyticsArea = useAnalyticsArea(); const {setDisplayAskSeerFeedback, askSeerNLQueryRef, askSeerSuggestedQueryRef} = useSearchQueryBuilder(); - const handleClick = (correct: 'yes' | 'no') => { - trackAnalytics('trace.explorer.ai_query_feedback', { + const handleClick = (type: 'positive' | 'negative') => { + trackAnalytics('ai_query.feedback', { organization, - correct_query_results: correct, + area: analyticsArea, + type, natural_language_query: askSeerNLQueryRef.current ?? '', - query: askSeerSuggestedQueryRef.current ?? '', + suggested_query: askSeerSuggestedQueryRef.current ?? '', }); askSeerNLQueryRef.current = null; askSeerSuggestedQueryRef.current = null; @@ -37,7 +40,7 @@ export function AskSeerFeedback() { + + + ```jsx - -``` - -### Busy Buttons - -Busy buttons should be used to indicate that an async action is in progress, usually connected to the `isPending` state of a query mutation. - -export function BusyDemo() { - const [busy, setBusy] = useState({cancel: false, submit: false}); - /** @param key {'cancel' | 'submit'} */ - const handleClick = key => { - setBusy(v => ({...v, [key]: true})); - setTimeout(() => { - setBusy(v => ({...v, [key]: false})); - }, 2500); - }; - return ( - - - - - ); -} - - - - -```jsx - - - - + + + ``` ## Icon-only Buttons diff --git a/static/app/components/core/button/button.tsx b/static/app/components/core/button/button.tsx index 7f2d698ccdf669..c305a65ee421a1 100644 --- a/static/app/components/core/button/button.tsx +++ b/static/app/components/core/button/button.tsx @@ -1,14 +1,11 @@ -import {useTheme} from '@emotion/react'; +import {keyframes} from '@emotion/react'; import styled from '@emotion/styled'; -import {AnimatePresence, motion} from 'framer-motion'; import {Flex} from '@sentry/scraps/layout'; -import {IndeterminateLoader} from '@sentry/scraps/loader'; import {useSizeContext} from '@sentry/scraps/sizeContext'; import {Tooltip} from '@sentry/scraps/tooltip'; import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults'; -import {testableTransition} from 'sentry/utils/testableTransition'; import { DO_NOT_USE_BUTTON_ICON_SIZES as BUTTON_ICON_SIZES, @@ -17,8 +14,6 @@ import { import type {DO_NOT_USE_ButtonProps as ButtonProps} from './types'; import {useButtonFunctionality} from './useButtonFunctionality'; -const MotionFlex = motion.create(Flex); - export type {ButtonProps}; export function Button({ @@ -31,7 +26,6 @@ export function Button({ }: ButtonProps) { const contextSize = useSizeContext(); const size = explicitSize ?? contextSize ?? 'md'; - const theme = useTheme(); const {handleClick, hasChildren, accessibleLabel} = useButtonFunctionality({ ...props, type, @@ -65,69 +59,36 @@ export function Button({ justify="center" minWidth="0" height="100%" - overflow="visible" whiteSpace="nowrap" + visibility={busy ? 'hidden' : undefined} > - - {props.icon && ( - - )} - {props.children} - - - {busy && ( - - - - )} - + {props.icon && ( + + )} + {props.children} + {busy && ( + + {({className}) => } + + )} @@ -142,3 +103,22 @@ const StyledButton = styled('button')< >` ${p => getButtonStyles(p)} `; + +const spin = keyframes` + to { + transform: rotate(360deg); + } +`; + +const BusySpinner = styled('span')` + &::after { + content: ''; + display: block; + width: 1em; + height: 1em; + border-radius: 50%; + border: 2px solid currentColor; + border-top-color: transparent; + animation: ${spin} 0.6s linear infinite; + } +`; diff --git a/static/app/components/core/button/styles.tsx b/static/app/components/core/button/styles.tsx index d206c1962e593e..82f6c9f5bc57a0 100644 --- a/static/app/components/core/button/styles.tsx +++ b/static/app/components/core/button/styles.tsx @@ -75,7 +75,7 @@ export function DO_NOT_USE_getButtonStyles( fontWeight: p.theme.font.weight.sans.medium, - opacity: p.disabled ? 0.6 : undefined, + opacity: p.busy || p.disabled ? 0.6 : undefined, cursor: 'pointer', '&[disabled]': { @@ -135,10 +135,6 @@ export function DO_NOT_USE_getButtonStyles( }, }, - '&[aria-busy="true"] > span:last-child': { - overflow: 'visible', - }, - '> span:last-child': { zIndex: 1, position: 'relative', @@ -184,7 +180,7 @@ export function DO_NOT_USE_getButtonStyles( }, }, - '&:disabled, &[aria-disabled="true"], &[aria-busy="true"]': { + '&:disabled, &[aria-disabled="true"]': { '&::after': { transform: 'translateY(0px)', }, @@ -193,10 +189,6 @@ export function DO_NOT_USE_getButtonStyles( }, }, - '&[aria-busy="true"]': { - cursor: 'progress', - }, - ...(p.priority === 'link' && { transform: 'translateY(0px)', diff --git a/static/app/components/core/loader/indeterminateLoader.tsx b/static/app/components/core/loader/indeterminateLoader.tsx deleted file mode 100644 index bf723e44a29098..00000000000000 --- a/static/app/components/core/loader/indeterminateLoader.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import {useEffect, useRef, useState} from 'react'; -import {keyframes} from '@emotion/react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import {useResizeObserver} from '@react-aria/utils'; -import {AnimatePresence, motion} from 'framer-motion'; - -import {Stack} from '@sentry/scraps/layout'; - -import {testableTransition} from 'sentry/utils/testableTransition'; - -// required to break import cycle -// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths -import {Text} from '../text/text'; - -interface IndeterminateLoaderProps extends React.HTMLAttributes { - messages?: React.ReactNode[]; - variant?: 'vibrant' | 'monochrome'; -} - -const SQUIGGLE_TILE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='1 0 16 8'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M17 6c-4 0-4-4-8-4S5 6 1 6'/%3E%3C/svg%3E")`; - -const indeterminateSlow = keyframes` - 0% { left: -35%; right: 100%; } - 60% { left: 100%; right: -90%; } - 100% { left: 100%; right: -90%; } -`; - -const indeterminateFast = keyframes` - 0% { left: -200%; right: 100%; } - 60% { left: 107%; right: -8%; } - 100% { left: 107%; right: -8%; } -`; - -// Lerp animation timing based on track width. -// Small (~128px): 2.0s duration, 1.0s delay -// Large (~400px+): 3.2s duration, 1.6s delay -const WIDTH = {MIN: 128, MAX: 400}; -const DURATION = {MIN: 2.0, MAX: 2.8}; -const DELAY = {MIN: 0.8, MAX: 1.2}; - -function lerp(min: number, max: number, t: number): number { - return min + (max - min) * Math.min(1, Math.max(0, t)); -} - -function useAnimationTiming() { - const ref = useRef(null); - const [duration, setDuration] = useState(DURATION.MAX); - const [delay, setDelay] = useState(DELAY.MAX); - - useResizeObserver({ - ref, - onResize() { - const w = ref.current?.offsetWidth ?? WIDTH.MAX; - const t = (w - WIDTH.MIN) / (WIDTH.MAX - WIDTH.MIN); - setDuration(lerp(DURATION.MIN, DURATION.MAX, t)); - setDelay(lerp(DELAY.MIN, DELAY.MAX, t)); - }, - }); - - return {ref, duration, delay}; -} - -const MESSAGE_INTERVAL_MS = 10_000; - -function useMessageCycler(messages: React.ReactNode[]) { - const [index, setIndex] = useState(0); - - useEffect(() => { - if (messages.length <= 1 || index >= messages.length - 1) { - return undefined; - } - const timer = setTimeout(() => setIndex(i => i + 1), MESSAGE_INTERVAL_MS); - return () => clearTimeout(timer); - }, [index, messages.length]); - - return {message: messages.length > 0 ? messages[index] : null, index}; -} - -export function IndeterminateLoader({ - variant = 'vibrant', - messages, - ...props -}: IndeterminateLoaderProps) { - const theme = useTheme(); - const {ref, duration, delay} = useAnimationTiming(); - const {message: currentMessage, index: messageIndex} = useMessageCycler(messages ?? []); - - const track = ( - - - - - - - ); - - if (!messages?.length) { - return track; - } - - return ( - - {track} - - - - {currentMessage} - - - - - - ); -} - -const dotFadeInOut = keyframes` - 0%, 30% { opacity: 0; } - 40%, 70% { opacity: 1; } - 80%, 100% { opacity: 0; } -`; - -function Ellipsis() { - return ( - - . - . - . - - ); -} - -const Dot = styled('span')<{delay: number}>` - opacity: 0; - animation: ${dotFadeInOut} 2.5s ${p => p.delay}s infinite; -`; - -const Track = styled('div')<{color: string; opacity: string}>` - position: relative; - overflow: hidden; - width: 100%; - width: calc(round(down, 100% - 16px, 8px) + 16px); - height: 8px; - - &::before { - content: ''; - position: absolute; - inset: 0; - background: ${p => p.color}; - opacity: ${p => p.opacity}; - mask-image: ${SQUIGGLE_TILE}; - mask-repeat: repeat-x; - mask-size: 16px 8px; - -webkit-mask-image: ${SQUIGGLE_TILE}; - -webkit-mask-repeat: repeat-x; - -webkit-mask-size: 16px 8px; - } -`; - -const ColorMask = styled('span')` - position: absolute; - inset: 0; - mask-image: ${SQUIGGLE_TILE}; - mask-repeat: repeat-x; - mask-size: 16px 8px; - -webkit-mask-image: ${SQUIGGLE_TILE}; - -webkit-mask-repeat: repeat-x; - -webkit-mask-size: 16px 8px; -`; - -const Bar = styled('span')<{ - animation: ReturnType; - color: string; - delay: string; - duration: string; - timing: string; -}>` - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - background: ${p => p.color}; - animation: ${p => p.animation} ${p => p.duration} ${p => p.timing} ${p => p.delay} - infinite backwards; -`; diff --git a/static/app/components/core/loader/index.tsx b/static/app/components/core/loader/index.tsx deleted file mode 100644 index f763feb1695b1c..00000000000000 --- a/static/app/components/core/loader/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export {IndeterminateLoader} from './indeterminateLoader'; diff --git a/static/app/components/core/loader/loader.mdx b/static/app/components/core/loader/loader.mdx deleted file mode 100644 index cb33feafb2e88a..00000000000000 --- a/static/app/components/core/loader/loader.mdx +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: IndeterminateLoader -description: An animated squiggle loader that fills its container to indicate indeterminate progress. -category: status -source: '@sentry/scraps/loader' -resources: - js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/loader/indeterminateLoader.tsx ---- - -import {Container, Flex} from '@sentry/scraps/layout'; -import {Text} from '@sentry/scraps/text'; -import {IndeterminateLoader} from '@sentry/scraps/loader'; - -import {Demo} from 'sentry/stories/demo'; - -export const documentation = import('!!type-loader!@sentry/scraps/loader'); - -The `IndeterminateLoader` renders a repeating squiggle pattern with an animated color wipe to indicate indeterminate loading progress. It fills 100% of its parent's width. - -## Usage - - - - - -```jsx - -``` - -## Messages - -Pass an array of `messages` to step through loading messages as the loader runs. - - - - - -```jsx - -``` - -## Contained Width - -Constrain the loader's width by wrapping it in a sized container. - - - - - - - - - - - - - -```jsx - - - - - - - - - -``` - -## Monochrome - -Use `variant="monochrome"` to inherit `currentColor` from the parent. The track renders at 25% opacity and the accent at full opacity. - -This is used internally in the Button component. - - - - - - - - - - - - - -```jsx - - - -``` - -## Accessibility - -The component renders with `role="progressbar"` and a default `aria-label` of `"Loading"`. Override the label to provide more specific context: - -```jsx - -``` From cfd8adf1b722db524be99f822ff8d5d18af378ef Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Thu, 2 Apr 2026 11:08:37 -0700 Subject: [PATCH 25/80] chore(ACI): Publish project based endpoint, unpublish org based one (#112057) Publish documentation for the project based detector creation endpoint and unpublish the organization based one. --- .../workflow_engine/endpoints/organization_detector_index.py | 2 +- .../endpoints/organization_project_detector_index.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sentry/workflow_engine/endpoints/organization_detector_index.py b/src/sentry/workflow_engine/endpoints/organization_detector_index.py index b6c535ad9e3033..bb0ea827d69120 100644 --- a/src/sentry/workflow_engine/endpoints/organization_detector_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_detector_index.py @@ -148,7 +148,7 @@ def get_detector_validator( class OrganizationDetectorIndexEndpoint(OrganizationEndpoint): publish_status = { "GET": ApiPublishStatus.PUBLIC, - "POST": ApiPublishStatus.PUBLIC, + "POST": ApiPublishStatus.EXPERIMENTAL, "PUT": ApiPublishStatus.PUBLIC, "DELETE": ApiPublishStatus.PUBLIC, } diff --git a/src/sentry/workflow_engine/endpoints/organization_project_detector_index.py b/src/sentry/workflow_engine/endpoints/organization_project_detector_index.py index 5e912d24553ae0..e972d7adfff532 100644 --- a/src/sentry/workflow_engine/endpoints/organization_project_detector_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_project_detector_index.py @@ -17,6 +17,7 @@ RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED, ) +from sentry.apidocs.examples.workflow_engine_examples import WorkflowEngineExamples from sentry.apidocs.parameters import GlobalParams from sentry.incidents.grouptype import MetricIssue from sentry.models.project import Project @@ -35,7 +36,7 @@ class OrganizationProjectDetectorPermission(ProjectPermission): @extend_schema(tags=["Monitors"]) class OrganizationProjectDetectorIndexEndpoint(ProjectEndpoint): publish_status = { - "POST": ApiPublishStatus.EXPERIMENTAL, + "POST": ApiPublishStatus.PUBLIC, } owner = ApiOwner.ALERTS_NOTIFICATIONS permission_classes = (OrganizationProjectDetectorPermission,) @@ -54,6 +55,7 @@ class OrganizationProjectDetectorIndexEndpoint(ProjectEndpoint): 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND, }, + examples=WorkflowEngineExamples.CREATE_DETECTOR, ) def post(self, request: Request, project: Project) -> Response: """ From df00d4655c3b04a63b263027003a73cb706877cd Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Thu, 2 Apr 2026 11:15:06 -0700 Subject: [PATCH 26/80] ref(preprod): Call status check tasks synchronously (#112120) Replace `apply_async` calls with direct synchronous invocations for preprod status check tasks in the approval endpoint and GitHub check run webhook handler. This ensures status checks run inline rather than being dispatched to the Celery task queue, giving immediate feedback on approval actions. [sentry URL for tracking the latency of our status check tasks](https://sentry.sentry.io/explore/traces/?aggregateField=%7B%22groupBy%22%3A%22transaction%22%7D&aggregateField=%7B%22yAxes%22%3A%5B%22avg%28span.duration%29%22%2C%22p95%28span.duration%29%22%2C%22count%28%29%22%5D%7D&mode=aggregate&project=1&query=is_transaction%3Atrue%20%28transaction%3A%22sentry.preprod.tasks.create_preprod_status_check%22%20OR%20transaction%3A%22sentry.preprod.tasks.create_preprod_snapshot_status_check%22%20%29&sort=-p95%28span.duration%29&statsPeriod=7d&table=span) --------- Co-authored-by: Claude Opus 4.6 --- .../api/endpoints/preprod_artifact_approve.py | 8 +++----- .../preprod/vcs/webhooks/github_check_run.py | 16 ++++++---------- .../vcs/webhooks/test_github_check_run.py | 8 +++----- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_approve.py b/src/sentry/preprod/api/endpoints/preprod_artifact_approve.py index 091a9fb6f848c1..27fd6fac9ca38f 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_approve.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_approve.py @@ -89,11 +89,9 @@ def post(self, request: Request, organization: Organization, artifact_id: str) - ).delete() task = STATUS_CHECK_TASK_MAP[feature_type] - task.apply_async( - kwargs={ - "preprod_artifact_id": artifact.id, - "caller": "approval_endpoint", - } + task( + preprod_artifact_id=artifact.id, + caller="approval_endpoint", ) return Response({"detail": "Approved"}, status=201) diff --git a/src/sentry/preprod/vcs/webhooks/github_check_run.py b/src/sentry/preprod/vcs/webhooks/github_check_run.py index b706c954dabb28..6f14af81b92746 100644 --- a/src/sentry/preprod/vcs/webhooks/github_check_run.py +++ b/src/sentry/preprod/vcs/webhooks/github_check_run.py @@ -215,18 +215,14 @@ def handle_preprod_check_run_event( ) if identifier == APPROVE_SIZE_ACTION_IDENTIFIER: - create_preprod_status_check_task.apply_async( - kwargs={ - "preprod_artifact_id": artifact.id, - "caller": "github_approve_webhook", - } + create_preprod_status_check_task( + preprod_artifact_id=artifact.id, + caller="github_approve_webhook", ) elif identifier == APPROVE_SNAPSHOT_ACTION_IDENTIFIER: - create_preprod_snapshot_status_check_task.apply_async( - kwargs={ - "preprod_artifact_id": artifact.id, - "caller": "github_approve_webhook", - } + create_preprod_snapshot_status_check_task( + preprod_artifact_id=artifact.id, + caller="github_approve_webhook", ) else: raise ValueError(f"Unknown identifier: {identifier}") diff --git a/tests/sentry/preprod/vcs/webhooks/test_github_check_run.py b/tests/sentry/preprod/vcs/webhooks/test_github_check_run.py index 93b62ba36d16ee..35f28bdd6afc4c 100644 --- a/tests/sentry/preprod/vcs/webhooks/test_github_check_run.py +++ b/tests/sentry/preprod/vcs/webhooks/test_github_check_run.py @@ -193,11 +193,9 @@ def test_creates_approval_for_valid_request(self) -> None: assert approval.extras == {"github": {"id": 12345, "login": "octocat"}} assert approval.approved_at is not None - mock_task.apply_async.assert_called_once_with( - kwargs={ - "preprod_artifact_id": artifact.id, - "caller": "github_approve_webhook", - } + mock_task.assert_called_once_with( + preprod_artifact_id=artifact.id, + caller="github_approve_webhook", ) def test_creates_approvals_for_all_sibling_artifacts(self) -> None: From f124333f2253867b24a444bd1ad883d35acd4250 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Thu, 2 Apr 2026 11:25:49 -0700 Subject: [PATCH 27/80] feat(aci): Add numbers to monitor and alert form sections (#111898) Finishes implementing the new monitor form design. - Adds step numbers to each section - Combines ownership and description into the same section - Adds some new descriptions --- .../workflowEngine/ui/formSection.tsx | 7 +- .../components/details/cron/index.spec.tsx | 4 +- .../components/forms/automateSection.tsx | 5 +- .../components/forms/common/assignSection.tsx | 4 +- .../forms/common/describeSection.tsx | 3 +- .../forms/common/issuePreviewSection.tsx | 9 +- .../common/projectEnvironmentSection.tsx | 7 +- .../forms/common/projectSection.tsx | 3 +- .../forms/common/useStepCounter.tsx | 14 +++ .../components/forms/cron/detect.tsx | 4 +- .../components/forms/cron/index.spec.tsx | 18 ++-- .../detectors/components/forms/cron/index.tsx | 18 ++-- .../components/forms/cron/resolve.tsx | 4 +- .../components/forms/error/index.tsx | 10 +-- .../components/forms/metric/metric.spec.tsx | 5 +- .../components/forms/metric/metric.tsx | 88 ++++++++++++++++--- .../forms/metric/metricIssuePreview.tsx | 4 +- .../forms/metric/templateSection.tsx | 4 +- .../forms/mobileBuild/detectSection.tsx | 4 +- .../components/forms/mobileBuild/index.tsx | 11 +-- .../forms/mobileBuild/previewSection.tsx | 4 +- .../components/forms/uptime/detect/index.tsx | 4 +- .../components/forms/uptime/index.tsx | 17 ++-- .../components/forms/uptime/resolve.tsx | 4 +- .../components/forms/uptime/verification.tsx | 9 +- 25 files changed, 181 insertions(+), 83 deletions(-) create mode 100644 static/app/views/detectors/components/forms/common/useStepCounter.tsx diff --git a/static/app/components/workflowEngine/ui/formSection.tsx b/static/app/components/workflowEngine/ui/formSection.tsx index 93b5fa4b34ed5a..64e44e5a3ea5d1 100644 --- a/static/app/components/workflowEngine/ui/formSection.tsx +++ b/static/app/components/workflowEngine/ui/formSection.tsx @@ -10,6 +10,7 @@ type FormSectionProps = { className?: string; defaultExpanded?: boolean; description?: React.ReactNode; + step?: number; trailingItems?: React.ReactNode; }; @@ -18,6 +19,7 @@ export function FormSection({ className, title, description, + step, trailingItems, defaultExpanded = true, }: FormSectionProps) { @@ -30,7 +32,10 @@ export function FormSection({ className={className} > - {title} + + {step ? `${step}. ` : ''} + {title} + diff --git a/static/app/views/detectors/components/details/cron/index.spec.tsx b/static/app/views/detectors/components/details/cron/index.spec.tsx index 2123a6a8c92cfc..77d6fc3909b6f6 100644 --- a/static/app/views/detectors/components/details/cron/index.spec.tsx +++ b/static/app/views/detectors/components/details/cron/index.spec.tsx @@ -208,9 +208,9 @@ describe('CronDetectorDetails - check-ins', () => { ); - // Wait for check-ins to load and find the table after the heading + // Wait for check-ins to load and find the table within the section const recentCheckInsHeading = await screen.findByText('Recent Check-Ins'); - const container = recentCheckInsHeading.parentElement!.parentElement!; + const container = recentCheckInsHeading.closest('section')!; const checkInTable = await within(container).findByRole('table'); // Find the "Started" column index diff --git a/static/app/views/detectors/components/forms/automateSection.tsx b/static/app/views/detectors/components/forms/automateSection.tsx index 947d7160d3c787..7e45ea3bb74803 100644 --- a/static/app/views/detectors/components/forms/automateSection.tsx +++ b/static/app/views/detectors/components/forms/automateSection.tsx @@ -17,7 +17,7 @@ import {ConnectAutomationsDrawer} from 'sentry/views/detectors/components/connec import {ConnectedAutomationsList} from 'sentry/views/detectors/components/connectedAutomationList'; import {useDetectorFormContext} from 'sentry/views/detectors/components/forms/context'; -export function AutomateSection() { +export function AutomateSection({step}: {step?: number}) { const ref = useRef(null); const formContext = useContext(FormContext); const {openDrawer, closeDrawer, isDrawerOpen} = useDrawer(); @@ -77,7 +77,7 @@ export function AutomateSection() { if (workflowIds.length > 0) { return ( - + diff --git a/static/app/views/detectors/components/forms/common/assignSection.tsx b/static/app/views/detectors/components/forms/common/assignSection.tsx index c5f2e7090a2116..ed5bae04a85448 100644 --- a/static/app/views/detectors/components/forms/common/assignSection.tsx +++ b/static/app/views/detectors/components/forms/common/assignSection.tsx @@ -27,12 +27,12 @@ function AssigneeField({projectId}: {projectId?: string}) { ); } -export function AssignSection() { +export function AssignSection({step}: {step?: number}) { const projectId = useFormField('projectId'); return ( - + diff --git a/static/app/views/detectors/components/forms/common/describeSection.tsx b/static/app/views/detectors/components/forms/common/describeSection.tsx index 6b0906436b5432..d972b2b8a401b3 100644 --- a/static/app/views/detectors/components/forms/common/describeSection.tsx +++ b/static/app/views/detectors/components/forms/common/describeSection.tsx @@ -5,10 +5,11 @@ import {Container} from 'sentry/components/workflowEngine/ui/container'; import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {t} from 'sentry/locale'; -export function DescribeSection() { +export function DescribeSection({step}: {step?: number}) { return ( diff --git a/static/app/views/detectors/components/forms/common/issuePreviewSection.tsx b/static/app/views/detectors/components/forms/common/issuePreviewSection.tsx index 9d8463a32dc727..7ce6ae3d607680 100644 --- a/static/app/views/detectors/components/forms/common/issuePreviewSection.tsx +++ b/static/app/views/detectors/components/forms/common/issuePreviewSection.tsx @@ -2,10 +2,17 @@ import {Container} from 'sentry/components/workflowEngine/ui/container'; import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {t} from 'sentry/locale'; -export function IssuePreviewSection({children}: {children: React.ReactNode}) { +export function IssuePreviewSection({ + children, + step, +}: { + children: React.ReactNode; + step?: number; +}) { return ( diff --git a/static/app/views/detectors/components/forms/common/projectSection.tsx b/static/app/views/detectors/components/forms/common/projectSection.tsx index b259baf3b2532f..1d123d2d82f098 100644 --- a/static/app/views/detectors/components/forms/common/projectSection.tsx +++ b/static/app/views/detectors/components/forms/common/projectSection.tsx @@ -3,10 +3,11 @@ import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {t} from 'sentry/locale'; import {ProjectField} from 'sentry/views/detectors/components/forms/common/projectField'; -export function ProjectSection() { +export function ProjectSection({step}: {step?: number}) { return ( diff --git a/static/app/views/detectors/components/forms/common/useStepCounter.tsx b/static/app/views/detectors/components/forms/common/useStepCounter.tsx new file mode 100644 index 00000000000000..2cb929c61c3483 --- /dev/null +++ b/static/app/views/detectors/components/forms/common/useStepCounter.tsx @@ -0,0 +1,14 @@ +/** + * Returns a function that produces an incrementing step number on each call. + * This is useful for forms which have conditional sections that need to be numbered correctly. + */ +export function useStepCounter() { + let counter = 0; + + const nextStep = () => { + counter++; + return counter; + }; + + return nextStep; +} diff --git a/static/app/views/detectors/components/forms/cron/detect.tsx b/static/app/views/detectors/components/forms/cron/detect.tsx index dedcb2ecd77059..7e312dd6f1ea9f 100644 --- a/static/app/views/detectors/components/forms/cron/detect.tsx +++ b/static/app/views/detectors/components/forms/cron/detect.tsx @@ -198,10 +198,10 @@ function Thresholds() { ); } -export function CronDetectorFormDetectSection() { +export function CronDetectorFormDetectSection({step}: {step?: number}) { return ( - +
{t('Set your schedule')} diff --git a/static/app/views/detectors/components/forms/cron/index.spec.tsx b/static/app/views/detectors/components/forms/cron/index.spec.tsx index 42bc8ff1915528..3e6f53dd7767f5 100644 --- a/static/app/views/detectors/components/forms/cron/index.spec.tsx +++ b/static/app/views/detectors/components/forms/cron/index.spec.tsx @@ -57,9 +57,9 @@ describe('NewCronDetectorForm', () => { renderForm(); // Form sections should be visible - expect(await screen.findByText('Detect')).toBeInTheDocument(); - expect(screen.getByText('Assign')).toBeInTheDocument(); - expect(screen.getByText('Description')).toBeInTheDocument(); + expect(await screen.findByText(/Detect/)).toBeInTheDocument(); + expect(screen.getByText(/Assign/)).toBeInTheDocument(); + expect(screen.getByText(/Description/)).toBeInTheDocument(); // Create Monitor button should be present and enabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); @@ -79,9 +79,9 @@ describe('NewCronDetectorForm', () => { await screen.findByText('Step 2 of 2'); // Form sections should be hidden - expect(screen.queryByText('Detect')).not.toBeInTheDocument(); - expect(screen.queryByText('Assign')).not.toBeInTheDocument(); - expect(screen.queryByText('Description')).not.toBeInTheDocument(); + expect(screen.queryByText(/Detect/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Assign/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Description/)).not.toBeInTheDocument(); // Create Monitor button should be present but disabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); @@ -98,9 +98,9 @@ describe('NewCronDetectorForm', () => { }); // Form sections should be visible even with platform set, because guide is "manual" - expect(await screen.findByText('Detect')).toBeInTheDocument(); - expect(screen.getByText('Assign')).toBeInTheDocument(); - expect(screen.getByText('Description')).toBeInTheDocument(); + expect(await screen.findByText(/Detect/)).toBeInTheDocument(); + expect(screen.getByText(/Assign/)).toBeInTheDocument(); + expect(screen.getByText(/Description/)).toBeInTheDocument(); // Create Monitor button should be present and enabled const createButton = screen.getByRole('button', {name: 'Create Monitor'}); diff --git a/static/app/views/detectors/components/forms/cron/index.tsx b/static/app/views/detectors/components/forms/cron/index.tsx index 77073d703a52cb..2d53658f1eec1a 100644 --- a/static/app/views/detectors/components/forms/cron/index.tsx +++ b/static/app/views/detectors/components/forms/cron/index.tsx @@ -29,6 +29,15 @@ function useIsShowingPlatformGuide() { return platformKey && guideKey !== 'manual'; } +const FORM_SECTIONS = [ + ProjectSection, + CronDetectorFormDetectSection, + CronDetectorFormResolveSection, + AssignSection, + DescribeSection, + AutomateSection, +]; + function CronDetectorForm({detector}: {detector?: CronDetector}) { const dataSource = detector?.dataSources[0]; const theme = useTheme(); @@ -44,12 +53,9 @@ function CronDetectorForm({detector}: {detector?: CronDetector}) { )} - - - - - - + {FORM_SECTIONS.map((FormSection, index) => ( + + ))} ); diff --git a/static/app/views/detectors/components/forms/cron/resolve.tsx b/static/app/views/detectors/components/forms/cron/resolve.tsx index 04e89ff3ead585..e62533dcdd1dbe 100644 --- a/static/app/views/detectors/components/forms/cron/resolve.tsx +++ b/static/app/views/detectors/components/forms/cron/resolve.tsx @@ -7,10 +7,10 @@ import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {t} from 'sentry/locale'; import {CRON_DEFAULT_RECOVERY_THRESHOLD} from 'sentry/views/detectors/components/forms/cron/fields'; -export function CronDetectorFormResolveSection() { +export function CronDetectorFormResolveSection({step}: {step?: number}) { return ( - + - + {tct( 'An error issue will be created when a new issue group is detected. [link:Manage Grouping Rules]', @@ -50,7 +50,7 @@ function ErrorDetectorForm({detector}: {detector: ErrorDetector}) { - + {tct( 'Sentry will attempt to automatically assign new issues based on [link:Ownership Rules].', @@ -66,7 +66,7 @@ function ErrorDetectorForm({detector}: {detector: ErrorDetector}) { - + {tct( 'New error issues are prioritized based on log level. [link:Learn more about Issue Priority]', @@ -80,7 +80,7 @@ function ErrorDetectorForm({detector}: {detector: ErrorDetector}) { - + {tct( 'Issues may be automatically resolved based on [link:Auto Resolve Settings].', @@ -95,7 +95,7 @@ function ErrorDetectorForm({detector}: {detector: ErrorDetector}) { - + ); } diff --git a/static/app/views/detectors/components/forms/metric/metric.spec.tsx b/static/app/views/detectors/components/forms/metric/metric.spec.tsx index 14dbf9248b79df..44b258cd9fda9a 100644 --- a/static/app/views/detectors/components/forms/metric/metric.spec.tsx +++ b/static/app/views/detectors/components/forms/metric/metric.spec.tsx @@ -137,10 +137,7 @@ describe('NewMetricDetectorForm', () => { ).toBeInTheDocument(); // Change the assignee and verify it shows in the preview - await selectEvent.select( - screen.getByRole('textbox', {name: 'Default assignee'}), - 'Foo Bar' - ); + await selectEvent.select(screen.getByRole('textbox', {name: 'Assign'}), 'Foo Bar'); expect(within(preview).getByText('FB')).toBeInTheDocument(); }); diff --git a/static/app/views/detectors/components/forms/metric/metric.tsx b/static/app/views/detectors/components/forms/metric/metric.tsx index 0268a736da2ac7..9d778e3fbbebff 100644 --- a/static/app/views/detectors/components/forms/metric/metric.tsx +++ b/static/app/views/detectors/components/forms/metric/metric.tsx @@ -1,4 +1,4 @@ -import {Fragment, useContext, useEffect} from 'react'; +import {Fragment, useContext, useEffect, useMemo} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import toNumber from 'lodash/toNumber'; @@ -13,7 +13,10 @@ import type {RadioOption} from 'sentry/components/forms/controls/radioGroup'; import {NumberField} from 'sentry/components/forms/fields/numberField'; import {SegmentedRadioField} from 'sentry/components/forms/fields/segmentedRadioField'; import {SelectField} from 'sentry/components/forms/fields/selectField'; +import {SentryMemberTeamSelectorField} from 'sentry/components/forms/fields/sentryMemberTeamSelectorField'; +import {TextareaField} from 'sentry/components/forms/fields/textareaField'; import {FormContext} from 'sentry/components/forms/formContext'; +import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; import {Container} from 'sentry/components/workflowEngine/ui/container'; import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {IconWarning} from 'sentry/icons/iconWarning'; @@ -24,6 +27,7 @@ import {DataConditionType} from 'sentry/types/workflowEngine/dataConditions'; import type {Detector, MetricDetectorConfig} from 'sentry/types/workflowEngine/detectors'; import {generateFieldAsString} from 'sentry/utils/discover/fields'; import {useLocation} from 'sentry/utils/useLocation'; +import {useProjects} from 'sentry/utils/useProjects'; import { AlertRuleSensitivity, AlertRuleThresholdType, @@ -34,8 +38,6 @@ import { } from 'sentry/views/detectors/components/details/metric/transactionsDatasetWarning'; import {useIsMigratedExtrapolation} from 'sentry/views/detectors/components/details/metric/utils/useIsMigratedExtrapolation'; import {AutomateSection} from 'sentry/views/detectors/components/forms/automateSection'; -import {AssignSection} from 'sentry/views/detectors/components/forms/common/assignSection'; -import {DescribeSection} from 'sentry/views/detectors/components/forms/common/describeSection'; import {ProjectEnvironmentSection} from 'sentry/views/detectors/components/forms/common/projectEnvironmentSection'; import {EditDetectorLayout} from 'sentry/views/detectors/components/forms/editDetectorLayout'; import type {MetricDetectorFormData} from 'sentry/views/detectors/components/forms/metric/metricFormData'; @@ -74,14 +76,13 @@ function MetricDetectorForm() { - - - - - - - - + + + + + + + ); } @@ -396,7 +397,7 @@ function IntervalPicker() { ); } -function CustomizeMetricSection() { +function CustomizeMetricSection({step}: {step?: number}) { const detectionType = useMetricDetectorFormField( METRIC_DETECTOR_FORM_FIELDS.detectionType ); @@ -408,7 +409,7 @@ function CustomizeMetricSection() { return ( - + ('projectId'); + const {projects} = useProjects(); + const memberOfProjectSlugs = useMemo(() => { + const project = projects.find(p => p.id === projectId); + return project ? [project.slug] : undefined; + }, [projects, projectId]); + + return ( + + + + + + + + + ); +} + function TransactionsDatasetWarningListener() { const dataset = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.dataset); if (dataset !== DetectorDataset.TRANSACTIONS) { @@ -804,3 +852,15 @@ const RequiredAsterisk = styled('span')` color: ${p => p.theme.tokens.content.danger}; margin-left: ${p => p.theme.space['2xs']}; `; + +const OwnershipField = styled(SentryMemberTeamSelectorField)` + padding: ${p => p.theme.space.lg} 0; +`; + +// Min height helps prevent resize after placeholder is replaced with user input +const MinHeightTextarea = styled(TextareaField)` + padding: ${p => p.theme.space.lg} 0; + textarea { + min-height: 140px; + } +`; diff --git a/static/app/views/detectors/components/forms/metric/metricIssuePreview.tsx b/static/app/views/detectors/components/forms/metric/metricIssuePreview.tsx index c50799b6113bde..f7ca68d5cbc286 100644 --- a/static/app/views/detectors/components/forms/metric/metricIssuePreview.tsx +++ b/static/app/views/detectors/components/forms/metric/metricIssuePreview.tsx @@ -76,7 +76,7 @@ function useMetricIssuePreviewSubtitle() { } } -export function MetricIssuePreview() { +export function MetricIssuePreview({step}: {step?: number}) { const name = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.name); const owner = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.owner); const subtitle = useMetricIssuePreviewSubtitle(); @@ -84,7 +84,7 @@ export function MetricIssuePreview() { const {project} = useDetectorFormContext(); return ( - + = { */ const CUSTOM_TEMPLATE_VALUE = '__custom__' as const; -export function TemplateSection() { +export function TemplateSection({step}: {step?: number}) { const formContext = useContext(FormContext); const datasetChoices = useDatasetChoices(); const allowedDatasets = useMemo( @@ -122,7 +122,7 @@ export function TemplateSection() { return ( - + - + - + - + - - - - + + + + ); } diff --git a/static/app/views/detectors/components/forms/mobileBuild/previewSection.tsx b/static/app/views/detectors/components/forms/mobileBuild/previewSection.tsx index 0d04f0eafcc6da..92fe05080bc204 100644 --- a/static/app/views/detectors/components/forms/mobileBuild/previewSection.tsx +++ b/static/app/views/detectors/components/forms/mobileBuild/previewSection.tsx @@ -11,7 +11,7 @@ import { guessPlatformForProject, } from 'sentry/views/settings/project/preprod/types'; -export function MobileBuildPreviewSection() { +export function MobileBuildPreviewSection({step}: {step: number}) { const measurement = usePreprodDetectorFormField(PREPROD_DETECTOR_FORM_FIELDS.measurement) ?? 'install_size'; @@ -39,7 +39,7 @@ export function MobileBuildPreviewSection() { const thresholdDisplay = highThreshold ? `${threshold} ${thresholdUnit}` : '\u2026'; return ( - + - + ({ diff --git a/static/app/views/detectors/components/forms/uptime/index.tsx b/static/app/views/detectors/components/forms/uptime/index.tsx index ac1df22375cbe0..8cc3a603631564 100644 --- a/static/app/views/detectors/components/forms/uptime/index.tsx +++ b/static/app/views/detectors/components/forms/uptime/index.tsx @@ -18,6 +18,7 @@ import { type EnvironmentConfig, } from 'sentry/views/detectors/components/forms/common/projectEnvironmentSection'; import {useSetAutomaticName} from 'sentry/views/detectors/components/forms/common/useSetAutomaticName'; +import {useStepCounter} from 'sentry/views/detectors/components/forms/common/useStepCounter'; import {EditDetectorLayout} from 'sentry/views/detectors/components/forms/editDetectorLayout'; import {NewDetectorLayout} from 'sentry/views/detectors/components/forms/newDetectorLayout'; import {ConnectedTestUptimeMonitorButton} from 'sentry/views/detectors/components/forms/uptime/connectedTestUptimeMonitorButton'; @@ -38,6 +39,8 @@ const ENVIRONMENT_CONFIG: EnvironmentConfig = { function UptimeDetectorForm() { const theme = useTheme(); + const {hasRuntimeAssertions} = useUptimeAssertionFeatures(); + const nextStep = useStepCounter(); useSetAutomaticName(form => { const url = form.getValue('url'); @@ -61,13 +64,13 @@ function UptimeDetectorForm() { - - - - - - - + + + {hasRuntimeAssertions && } + + + + ); } diff --git a/static/app/views/detectors/components/forms/uptime/resolve.tsx b/static/app/views/detectors/components/forms/uptime/resolve.tsx index 08e3439286c200..1cddc3cb991d57 100644 --- a/static/app/views/detectors/components/forms/uptime/resolve.tsx +++ b/static/app/views/detectors/components/forms/uptime/resolve.tsx @@ -8,10 +8,10 @@ import {t, tct} from 'sentry/locale'; import {getDuration} from 'sentry/utils/duration/getDuration'; import {UPTIME_DEFAULT_RECOVERY_THRESHOLD} from 'sentry/views/detectors/components/forms/uptime/fields'; -export function UptimeDetectorResolveSection() { +export function UptimeDetectorResolveSection({step}: {step?: number}) { return ( - +
Date: Thu, 2 Apr 2026 11:27:41 -0700 Subject: [PATCH 28/80] chore(ACI): Add custom metric and other aggregate options to API docs for detector creation (#112060) Update the `data_sources` docs in https://docs.sentry.io/api/monitors/create-a-monitor-for-a-project/ to add more detail, mostly related to aggregates and custom metrics, based on the documentation in https://docs.sentry.io/api/alerts/create-a-metric-alert-rule-for-an-organization/ --- .../validators/api_docs_help_text.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/sentry/workflow_engine/endpoints/validators/api_docs_help_text.py b/src/sentry/workflow_engine/endpoints/validators/api_docs_help_text.py index 984376ed4ffb06..a4b2dcb371c7c0 100644 --- a/src/sentry/workflow_engine/endpoints/validators/api_docs_help_text.py +++ b/src/sentry/workflow_engine/endpoints/validators/api_docs_help_text.py @@ -630,6 +630,7 @@ The data sources for the monitor to use based on what you want to measure. **Number of Errors Metric Monitor** + - `eventTypes`: Any of `error` or `default`. ```json [ { @@ -645,6 +646,7 @@ ``` **Users Experiencing Errors Metric Monitor** + - `eventTypes`: Any of `error` or `default`. ```json [ { @@ -709,6 +711,9 @@ ``` **Largest Contentful Paint Metric Monitor** + - `dataset`: If a custom percentile is used, dataset is `transactions`. Otherwise, dataset is `generic_metrics`. + - `aggregate`: Valid values are `avg(measurements.lcp)`, `p50(measurements.lcp)`, `p75(measurements.lcp)`, `p95(measurements.lcp)`, `p99(measurements.lcp)`, `p100(measurements.lcp)`, and `percentile(measurements.lcp,x)`, where `x` is your custom percentile. + ```json [ { @@ -723,6 +728,29 @@ }, ], ``` + + **Custom Metric Monitor** + - `dataset`: If a custom percentile is used, dataset is `transactions`. Otherwise, dataset is `generic_metrics`. + - `aggregate`: Valid values are: + `avg(x)`, where `x` is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. + `p50(x)`, where `x` is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. + `p75(x)`, where x is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. + `p95(x)`, where x is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. + `p99(x)`, where x is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. + `p100(x)`, where `x` is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`. + `percentile(x,y)`, where `x` is `transaction.duration`, `measurements.cls`, `measurements.fcp`, `measurements.fid`, `measurements.fp`, `measurements.lcp`, `measurements.ttfb`, or `measurements.ttfb.requesttime`, and `y` is the custom percentile. + `failure_rate()` + `apdex(x)`, where `x` is the value of the Apdex score. + `count()` + + ```json + [ + { + "aggregate": "p75(measurements.ttfb)" + "dataset": "generic_metrics", + "queryType": 1, + }, + ], """ DETECTOR_CONFIG_HELP_TEXT = """ From d3035c22c61c88d0d26d4e34a3cc03e28f219858 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Thu, 2 Apr 2026 11:37:25 -0700 Subject: [PATCH 29/80] fix(preprod): Use RPC service for cross-silo user lookup in snapshot endpoint (#112129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot GET endpoint (`preprod_artifact_snapshot.py`) was querying `User.objects.filter()` directly to resolve approver info. Since `User` is a control silo model and this is a cell silo endpoint, this raises `SiloLimit.AvailabilityError` in production when running in REGION mode. Replaces the direct ORM query with `user_service.get_many_by_id()` — the standard RPC service for cross-silo user resolution. `RpcUser` exposes the same `get_display_name()`, `.email`, and `.username` API so no other changes are needed. Also removes the now-resolved TODO comment in the approve endpoint that referenced this issue. Fixes SENTRY-5MQ6 Co-authored-by: Claude Opus 4.6 --- src/sentry/preprod/api/endpoints/preprod_artifact_approve.py | 3 --- src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_approve.py b/src/sentry/preprod/api/endpoints/preprod_artifact_approve.py index 27fd6fac9ca38f..a4eabbb6a98b36 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_approve.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_approve.py @@ -59,9 +59,6 @@ def post(self, request: Request, organization: Organization, artifact_id: str) - except (PreprodArtifact.DoesNotExist, ValueError): return Response({"detail": "Artifact not found"}, status=404) - # TODO(hybrid-cloud): approved_by is a User FK (control silo). This cell silo - # endpoint stores the ID, and the snapshot GET resolves it via User.objects.filter(). - # Both will need to use an RPC service when hybrid cloud enforcement is enabled. # exists()+create() instead of get_or_create — no unique constraint on this model # (see snapshots/tasks.py for rationale) already_approved = PreprodComparisonApproval.objects.filter( diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py index 921e9c1d65e036..b52166275c2806 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py @@ -61,7 +61,7 @@ ) from sentry.ratelimits.config import RateLimitConfig from sentry.types.ratelimit import RateLimit, RateLimitCategory -from sentry.users.models.user import User +from sentry.users.services.user.service import user_service from sentry.utils import metrics logger = logging.getLogger(__name__) @@ -349,7 +349,7 @@ def get(self, request: Request, organization: Organization, snapshot_id: str) -> if approved: sentry_user_ids = list({a.approved_by_id for a in approved if a.approved_by_id}) - users_by_id = {u.id: u for u in User.objects.filter(id__in=sentry_user_ids)} + users_by_id = {u.id: u for u in user_service.get_many_by_id(ids=sentry_user_ids)} approver_list: list[SnapshotApprover] = [] seen_approver_keys: set[str] = set() From 689fe1bfa2a8aa124a91502812fdf6e86deb1dd1 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 2 Apr 2026 11:40:36 -0700 Subject: [PATCH 30/80] ref(issues): Remove streamline flag from archive/resolve (#112128) make the "activities" prop required instead of optional, clean up old feature flag now ga --- static/app/components/archivedBox.spec.tsx | 47 ++------------ static/app/components/archivedBox.tsx | 32 ++-------- static/app/components/resolutionBox.spec.tsx | 21 ++----- static/app/components/resolutionBox.tsx | 63 ++++++------------- .../app/views/issueDetails/actions/index.tsx | 20 +++--- .../groupEventDetails/groupEventDetails.tsx | 7 +-- 6 files changed, 44 insertions(+), 146 deletions(-) diff --git a/static/app/components/archivedBox.spec.tsx b/static/app/components/archivedBox.spec.tsx index 689b11ea0726ed..cbcbb8f9187bf9 100644 --- a/static/app/components/archivedBox.spec.tsx +++ b/static/app/components/archivedBox.spec.tsx @@ -1,22 +1,15 @@ -import {OrganizationFixture} from 'sentry-fixture/organization'; - -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {render, screen} from 'sentry-test/reactTestingLibrary'; import {GroupSubstatus} from 'sentry/types/group'; -import * as analytics from 'sentry/utils/analytics'; import {ArchivedBox} from './archivedBox'; describe('ArchivedBox', () => { - const organization = OrganizationFixture(); - const analyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - it('handles ignoreUntil', () => { render( ); expect(screen.getByText(/This issue has been archived until/)).toBeInTheDocument(); @@ -26,7 +19,6 @@ describe('ArchivedBox', () => { ); expect( @@ -38,7 +30,6 @@ describe('ArchivedBox', () => { ); expect( @@ -50,7 +41,6 @@ describe('ArchivedBox', () => { ); expect( @@ -62,7 +52,6 @@ describe('ArchivedBox', () => { ); expect( @@ -71,11 +60,7 @@ describe('ArchivedBox', () => { }); it('handles archived forever', () => { render( - + ); expect(screen.getByText(/This issue has been archived forever/)).toBeInTheDocument(); }); @@ -84,34 +69,10 @@ describe('ArchivedBox', () => { , - { - organization, - } + /> ); expect( - screen.getByText( - /This issue has been archived\. It'll return to your inbox if it escalates/ - ) + screen.getByText(/This issue has been archived until it escalates/) ).toBeInTheDocument(); }); - it('tracks analytics when issue status docs is clicks', async () => { - render( - , - { - organization, - } - ); - await userEvent.click(screen.getByText('read the docs')); - - expect(analyticsSpy).toHaveBeenCalledWith( - 'issue_details.issue_status_docs_clicked', - expect.objectContaining({organization}) - ); - }); }); diff --git a/static/app/components/archivedBox.tsx b/static/app/components/archivedBox.tsx index c784c18f776cb8..b7838eb53ea357 100644 --- a/static/app/components/archivedBox.tsx +++ b/static/app/components/archivedBox.tsx @@ -1,44 +1,21 @@ -import {ExternalLink} from '@sentry/scraps/link'; - import {DateTime} from 'sentry/components/dateTime'; import {Duration} from 'sentry/components/duration'; import {BannerContainer, BannerSummary} from 'sentry/components/events/styles'; import {t} from 'sentry/locale'; import type {Group, IgnoredStatusDetails} from 'sentry/types/group'; import {GroupSubstatus} from 'sentry/types/group'; -import type {Organization} from 'sentry/types/organization'; -import {trackAnalytics} from 'sentry/utils/analytics'; interface ArchivedBoxProps { - organization: Organization; statusDetails: IgnoredStatusDetails; substatus: Group['substatus']; - hasStreamlinedUI?: boolean; } -export function renderArchiveReason({ - substatus, - statusDetails, - organization, - hasStreamlinedUI = false, -}: ArchivedBoxProps) { +export function renderArchiveReason({substatus, statusDetails}: ArchivedBoxProps) { const {ignoreUntil, ignoreCount, ignoreWindow, ignoreUserCount, ignoreUserWindow} = statusDetails; if (substatus === GroupSubstatus.ARCHIVED_UNTIL_ESCALATING) { - return hasStreamlinedUI - ? t('This issue has been archived until it escalates.') - : t( - "This issue has been archived. It'll return to your inbox if it escalates. To learn more, %s", - - trackAnalytics('issue_details.issue_status_docs_clicked', {organization}) - } - > - {t('read the docs')} - - ); + return t('This issue has been archived until it escalates.'); } if (ignoreUntil) { return t( @@ -81,11 +58,12 @@ export function renderArchiveReason({ return t('This issue has been archived forever.'); } -export function ArchivedBox({substatus, statusDetails, organization}: ArchivedBoxProps) { + +export function ArchivedBox({substatus, statusDetails}: ArchivedBoxProps) { return ( - {renderArchiveReason({substatus, statusDetails, organization})} + {renderArchiveReason({substatus, statusDetails})} ); diff --git a/static/app/components/resolutionBox.spec.tsx b/static/app/components/resolutionBox.spec.tsx index f34bbc56b3fe70..4a8f70d86d1a42 100644 --- a/static/app/components/resolutionBox.spec.tsx +++ b/static/app/components/resolutionBox.spec.tsx @@ -34,18 +34,12 @@ describe('ResolutionBox', () => { }); }); - it('handles default', () => { - const {container} = render( - - ); - expect(container).toHaveTextContent('This issue has been marked as resolved.'); - }); it('handles inNextRelease', () => { const {container} = render( ); expect(container).toHaveTextContent( @@ -66,7 +60,7 @@ describe('ResolutionBox', () => { }, }} project={project} - organization={organization} + activities={[]} /> ); expect(container).toHaveTextContent( @@ -99,7 +93,6 @@ describe('ResolutionBox', () => { }, }} project={project} - organization={organization} activities={[ { id: '1', @@ -141,7 +134,6 @@ describe('ResolutionBox', () => { actor: UserFixture(), }} project={project} - organization={organization} activities={[ { id: '1', @@ -178,7 +170,6 @@ describe('ResolutionBox', () => { actor: UserFixture(), }} project={project} - organization={organization} activities={[ { id: '1', @@ -202,7 +193,7 @@ describe('ResolutionBox', () => { inRelease: release.version, }} project={project} - organization={organization} + activities={[]} /> ); expect(container).toHaveTextContent( @@ -223,7 +214,7 @@ describe('ResolutionBox', () => { }, }} project={project} - organization={organization} + activities={[]} /> ); expect(container).toHaveTextContent( @@ -237,11 +228,11 @@ describe('ResolutionBox', () => { inCommit: CommitFixture(), }} project={project} - organization={organization} + activities={[]} /> ); expect(container).toHaveTextContent( - 'This issue has been marked as resolved by f7f395din' + 'This issue has been marked as resolved by f7f395d(in a year)' ); }); }); diff --git a/static/app/components/resolutionBox.tsx b/static/app/components/resolutionBox.tsx index 0dea723f135521..3987d7265bcceb 100644 --- a/static/app/components/resolutionBox.tsx +++ b/static/app/components/resolutionBox.tsx @@ -1,8 +1,6 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; -import {UserAvatar} from '@sentry/scraps/avatar'; - import {CommitLink} from 'sentry/components/commitLink'; import {BannerContainer, BannerSummary} from 'sentry/components/events/styles'; import {TimeSince} from 'sentry/components/timeSince'; @@ -13,35 +11,21 @@ import {t, tct} from 'sentry/locale'; import type {GroupActivity, ResolvedStatusDetails} from 'sentry/types/group'; import {GroupActivityType} from 'sentry/types/group'; import type {Repository} from 'sentry/types/integrations'; -import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import {useOrganization} from 'sentry/utils/useOrganization'; type Props = { - organization: Organization; + activities: GroupActivity[]; project: Project; // TODO(ts): This should be a union type `IgnoredStatusDetails | ResolvedStatusDetails` statusDetails: ResolvedStatusDetails; - activities?: GroupActivity[]; }; -export function renderResolutionReason({ - statusDetails, - project, - organization, - activities = [], - hasStreamlinedUI = false, -}: Props & {hasStreamlinedUI?: boolean}) { - const VersionComponent = hasStreamlinedUI ? StreamlinedVersion : Version; - const CommitLinkComponent = hasStreamlinedUI ? StreamlinedCommitLink : CommitLink; - +export function ResolutionReason({statusDetails, project, activities}: Props) { + const organization = useOrganization(); const actor = statusDetails.actor ? ( - {!hasStreamlinedUI && ( - - )} - - {statusDetails.actor.name} - + {statusDetails.actor.name} ) : null; @@ -62,7 +46,7 @@ export function renderResolutionReason({ projectSlug={project.slug} releaseVersion={releaseVersion} > - + ); return resolvedActor @@ -92,7 +76,7 @@ export function renderResolutionReason({ projectSlug={project.slug} releaseVersion={statusDetails.inRelease} > - + ); return resolvedActor @@ -106,27 +90,24 @@ export function renderResolutionReason({ return tct('This issue has been marked as resolved by [commit]', { commit: ( - - {statusDetails.inCommit.dateCreated && - (hasStreamlinedUI ? ( - - {'('} - - {')'} - - ) : ( + {statusDetails.inCommit.dateCreated && ( + + {'('} - ))} + {')'} + + )} ), }); } - return hasStreamlinedUI ? null : t('This issue has been marked as resolved.'); + return null; } export function ResolutionBox(props: Props) { @@ -134,19 +115,15 @@ export function ResolutionBox(props: Props) { - {renderResolutionReason(props)} + + + ); } const StyledTimeSince = styled(TimeSince)` - color: ${p => p.theme.tokens.content.secondary}; - margin-left: ${p => p.theme.space.xs}; - font-size: ${p => p.theme.font.size.sm}; -`; - -const StreamlinedTimeSince = styled(TimeSince)` color: ${p => p.theme.colors.green500}; font-size: inherit; text-decoration-style: dotted; @@ -164,7 +141,7 @@ const StyledIconCheckmark = styled(IconCheckmark)` } `; -const StreamlinedVersion = styled(Version)` +const StyledVersion = styled(Version)` color: ${p => p.theme.colors.green500}; font-weight: ${p => p.theme.font.weight.sans.medium}; text-decoration: underline; @@ -175,7 +152,7 @@ const StreamlinedVersion = styled(Version)` } `; -const StreamlinedCommitLink = styled(CommitLink)` +const StyledCommitLink = styled(CommitLink)` color: ${p => p.theme.colors.green500}; font-weight: ${p => p.theme.font.weight.sans.medium}; text-decoration: underline; diff --git a/static/app/views/issueDetails/actions/index.tsx b/static/app/views/issueDetails/actions/index.tsx index 0b54cc7f64949a..05833820536032 100644 --- a/static/app/views/issueDetails/actions/index.tsx +++ b/static/app/views/issueDetails/actions/index.tsx @@ -21,7 +21,7 @@ import {ResolveActions} from 'sentry/components/actions/resolve'; import {renderArchiveReason} from 'sentry/components/archivedBox'; import {openConfirmModal} from 'sentry/components/confirm'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {renderResolutionReason} from 'sentry/components/resolutionBox'; +import {ResolutionReason} from 'sentry/components/resolutionBox'; import { IconCheckmark, IconEllipsis, @@ -366,21 +366,17 @@ export function GroupActions({group, project, disabled, event}: GroupActionsProp {isResolved ? resolvedCopyCap || t('Resolved') : t('Archived')} - {group.status === 'resolved' - ? renderResolutionReason({ - statusDetails: group.statusDetails, - activities: group.activity, - hasStreamlinedUI: true, - project, - organization, - }) - : null} + {group.status === 'resolved' ? ( + + ) : null} {group.status === 'ignored' ? renderArchiveReason({ substatus: group.substatus, statusDetails: group.statusDetails, - organization, - hasStreamlinedUI: true, }) : null} diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx index aae8d7487b30e2..d09db4c5816a33 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx @@ -132,11 +132,7 @@ function GroupEventDetails() { if (group.status === 'ignored') { return ( - + ); } @@ -148,7 +144,6 @@ function GroupEventDetails() { statusDetails={group.statusDetails} activities={group.activity} project={project} - organization={organization} /> ); From 9362bc43befa916c45eedea92e22f10550f38cb7 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Thu, 2 Apr 2026 11:55:40 -0700 Subject: [PATCH 31/80] feat(replay): Add a button to toggle the replay-details layout between the default & video-only (#111944) --- .../replays/replaySidebarToggleButton.tsx | 2 +- static/app/components/replays/replayView.tsx | 33 ++++++++++- .../utils/replays/hooks/useReplayLayout.tsx | 59 +++++++------------ .../replays/hooks/useSplitPanelTracking.tsx | 21 +++---- static/app/utils/window/useWindowSize.ts | 37 ++++++++++++ .../replays/detail/layout/replayLayout.tsx | 37 ++++++++++-- .../replays/detail/layout/splitPanel.tsx | 17 +++++- 7 files changed, 143 insertions(+), 63 deletions(-) create mode 100644 static/app/utils/window/useWindowSize.ts diff --git a/static/app/components/replays/replaySidebarToggleButton.tsx b/static/app/components/replays/replaySidebarToggleButton.tsx index af9055be005bdb..b13f7847f84448 100644 --- a/static/app/components/replays/replaySidebarToggleButton.tsx +++ b/static/app/components/replays/replaySidebarToggleButton.tsx @@ -13,7 +13,7 @@ export function ReplaySidebarToggleButton({isOpen, setIsOpen}: Props) { diff --git a/static/app/components/replays/replayView.tsx b/static/app/components/replays/replayView.tsx index c53301fba79f7a..97a31fa2e277d7 100644 --- a/static/app/components/replays/replayView.tsx +++ b/static/app/components/replays/replayView.tsx @@ -1,6 +1,7 @@ import {Fragment, useState} from 'react'; import styled from '@emotion/styled'; +import {Button} from '@sentry/scraps/button'; import {Container, Flex} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -21,8 +22,10 @@ import {SentryPlayerRoot as ReplayPlayer} from 'sentry/components/replays/replay import {ReplayProcessingError} from 'sentry/components/replays/replayProcessingError'; import {ReplaySidebarToggleButton} from 'sentry/components/replays/replaySidebarToggleButton'; import {TextCopyInput} from 'sentry/components/textCopyInput'; +import {IconChevron} from 'sentry/icons/iconChevron'; import {IconFatal} from 'sentry/icons/iconFatal'; -import {tct} from 'sentry/locale'; +import {t, tct} from 'sentry/locale'; +import {LayoutKey} from 'sentry/utils/replays/hooks/useReplayLayout'; import {useReplayReader} from 'sentry/utils/replays/playback/providers/replayReaderProvider'; import {useIsFullscreen} from 'sentry/utils/window/useIsFullscreen'; import {Breadcrumbs} from 'sentry/views/replays/detail/breadcrumbs'; @@ -32,7 +35,9 @@ import {ReplayViewScale} from 'sentry/views/replays/detail/replayViewScale'; type Props = { isLoading: boolean; + layout: LayoutKey; toggleFullscreen: () => void; + toggleLayout: () => void; }; function FatalIconTooltip({error}: {error: Error | null}) { @@ -43,7 +48,7 @@ function FatalIconTooltip({error}: {error: Error | null}) { ); } -export function ReplayView({toggleFullscreen, isLoading}: Props) { +export function ReplayView({isLoading, layout, toggleFullscreen, toggleLayout}: Props) { const isFullscreen = useIsFullscreen(); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const replay = useReplayReader(); @@ -97,7 +102,29 @@ export function ReplayView({toggleFullscreen, isLoading}: Props) { isOpen={isSidebarOpen} setIsOpen={setIsSidebarOpen} /> - ) : null} + ) : ( + {() => ( - )} diff --git a/static/app/views/automations/components/editAutomationActions.tsx b/static/app/views/automations/components/editAutomationActions.tsx index 4e42416bc56041..ae4e50c081eeaf 100644 --- a/static/app/views/automations/components/editAutomationActions.tsx +++ b/static/app/views/automations/components/editAutomationActions.tsx @@ -73,7 +73,7 @@ export function EditAutomationActions({automation, form}: EditAutomationActionsP {() => ( - )} diff --git a/static/app/views/automations/edit.tsx b/static/app/views/automations/edit.tsx index 4e8208fb03ea46..7e3d7d16571285 100644 --- a/static/app/views/automations/edit.tsx +++ b/static/app/views/automations/edit.tsx @@ -5,6 +5,7 @@ import * as Sentry from '@sentry/react'; import {Flex, Stack} from '@sentry/scraps/layout'; +import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import type {FieldValue} from 'sentry/components/forms/model'; import {FormModel} from 'sentry/components/forms/model'; @@ -187,6 +188,7 @@ function AutomationEditForm({automation}: {automation: Automation}) { ...newAutomationData, }); onSubmitSuccess(formModel?.getData() ?? data); + addSuccessMessage(t('Alert updated')); trackAnalytics('automation.updated', { organization, ...analyticsPayload, diff --git a/static/app/views/automations/new.tsx b/static/app/views/automations/new.tsx index e6c005b3a18225..5ce31ff258def4 100644 --- a/static/app/views/automations/new.tsx +++ b/static/app/views/automations/new.tsx @@ -7,6 +7,7 @@ import {Observer} from 'mobx-react-lite'; import {Button} from '@sentry/scraps/button'; import {Flex, Stack} from '@sentry/scraps/layout'; +import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; import {FormModel} from 'sentry/components/forms/model'; import type {OnSubmitCallback} from 'sentry/components/forms/types'; @@ -153,6 +154,7 @@ export default function AutomationNewSettings() { try { const automation = await createAutomation(newAutomationData); onSubmitSuccess(formModel.getData()); + addSuccessMessage(t('Alert created')); trackAnalytics('automation.created', { organization, ...analyticsPayload, @@ -234,7 +236,7 @@ export default function AutomationNewSettings() { {() => ( - )} diff --git a/static/app/views/detectors/components/forms/common/footer.tsx b/static/app/views/detectors/components/forms/common/footer.tsx index e90f709edf6714..feb9a8cf6c2d4f 100644 --- a/static/app/views/detectors/components/forms/common/footer.tsx +++ b/static/app/views/detectors/components/forms/common/footer.tsx @@ -38,12 +38,8 @@ export function NewDetectorFooter({ - + + {({form}) => ( + + + {() => ( + + )} + + + )} + ); } diff --git a/static/app/views/detectors/hooks/useCreateDetectorFormSubmit.tsx b/static/app/views/detectors/hooks/useCreateDetectorFormSubmit.tsx index 0b27db0a2194eb..3893384dd43b83 100644 --- a/static/app/views/detectors/hooks/useCreateDetectorFormSubmit.tsx +++ b/static/app/views/detectors/hooks/useCreateDetectorFormSubmit.tsx @@ -60,6 +60,8 @@ export function useCreateDetectorFormSubmit< ); try { + formModel.setFormSaving(); + const resultDetector = await createDetector(payload); trackAnalytics('monitor.created', { @@ -68,7 +70,7 @@ export function useCreateDetectorFormSubmit< success: true, }); - addSuccessMessage(t('Monitor created successfully')); + addSuccessMessage(t('Monitor created')); if (onSuccess) { onSuccess(resultDetector); diff --git a/static/app/views/detectors/hooks/useEditDetectorFormSubmit.tsx b/static/app/views/detectors/hooks/useEditDetectorFormSubmit.tsx index a7fb78c150beb4..e3f99024d42808 100644 --- a/static/app/views/detectors/hooks/useEditDetectorFormSubmit.tsx +++ b/static/app/views/detectors/hooks/useEditDetectorFormSubmit.tsx @@ -44,6 +44,8 @@ export function useEditDetectorFormSubmit< return; } + formModel.setFormSaving(); + try { // Use getTransformedData() instead of raw data to apply field-level // getValue transformations (e.g., assertion normalization) @@ -63,7 +65,7 @@ export function useEditDetectorFormSubmit< ...getDetectorAnalyticsPayload(resultDetector), }); - addSuccessMessage(t('Monitor updated successfully')); + addSuccessMessage(t('Monitor updated')); if (onSuccess) { onSuccess(resultDetector as TDetector); From 7fd9ea6bc01eacff68cdfc6e08f921e63a611af6 Mon Sep 17 00:00:00 2001 From: Sofia Rest <68917129+srest2021@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:05:28 -0700 Subject: [PATCH 45/80] feat(autofix): add root cause as valid stopping point under feature flag (#112055) Allows root_cause as a valid automated run stopping point and org-level default, gated behind `organizations:root-cause-stopping-point`. - Org details PUT: dynamically validate defaultAutomatedRunStoppingPoint based on feature flag - Bulk settings POST: dynamically validate automatedRunStoppingPoint based on feature flag - Sync task: treat root_cause as a valid project stopping point when flag is enabled - Read paths (org serializer GET, get_org_default_seer_automation_handoff): sanitize stored values so root_cause falls back to the default if the flag is later turned off --- .../api/serializers/models/organization.py | 15 ++++++++---- .../core/endpoints/organization_details.py | 11 ++++++--- src/sentry/features/temporary.py | 2 ++ src/sentry/seer/autofix/utils.py | 22 ++++++++++++++++-- src/sentry/seer/blueprints/api.md | 22 +++++++++--------- ...rganization_autofix_automation_settings.py | 4 +++- src/sentry/tasks/seer/autofix.py | 4 ++-- .../endpoints/test_organization_details.py | 16 ++++++++++++- .../sentry/seer/autofix/test_autofix_utils.py | 9 +++++++- ...rganization_autofix_automation_settings.py | 23 +++++++++++++++++++ tests/sentry/tasks/seer/test_autofix.py | 20 ++++++++++++++++ 11 files changed, 123 insertions(+), 25 deletions(-) diff --git a/src/sentry/api/serializers/models/organization.py b/src/sentry/api/serializers/models/organization.py index 1f68aa0fc4f336..a2589b080de9d1 100644 --- a/src/sentry/api/serializers/models/organization.py +++ b/src/sentry/api/serializers/models/organization.py @@ -84,6 +84,7 @@ from sentry.organizations.absolute_url import generate_organization_url from sentry.organizations.services.organization import RpcOrganizationSummary from sentry.replays.models import OrganizationMemberReplayAccess +from sentry.seer.autofix.utils import get_valid_automated_run_stopping_points from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -601,6 +602,15 @@ def get_attrs( return attrs + def _get_default_automated_run_stopping_point(self, obj: Organization) -> str: + stopping_point = obj.get_option( + "sentry:default_automated_run_stopping_point", + SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, + ) + if stopping_point not in get_valid_automated_run_stopping_points(obj): + return SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + return stopping_point + def serialize( # type: ignore[override] self, obj: Organization, @@ -741,10 +751,7 @@ def serialize( # type: ignore[override] "defaultCodingAgentIntegrationId": obj.get_option( "sentry:seer_default_coding_agent_integration_id", None ), - "defaultAutomatedRunStoppingPoint": obj.get_option( - "sentry:default_automated_run_stopping_point", - SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, - ), + "defaultAutomatedRunStoppingPoint": self._get_default_automated_run_stopping_point(obj), "autoOpenPrs": bool( obj.get_option( "sentry:auto_open_prs", diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index 9f528e0c656145..b19dce5d82074e 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -110,6 +110,7 @@ from sentry.relay.datascrubbing import validate_pii_config_update, validate_pii_selectors from sentry.replays.models import OrganizationMemberReplayAccess from sentry.seer.autofix.constants import AutofixAutomationTuningSettings +from sentry.seer.autofix.utils import get_valid_automated_run_stopping_points from sentry.services.organization.provisioning import organization_provisioning_service from sentry.tasks.console_platform_cleanup import remove_inaccessible_console_platform_sources from sentry.users.services.user.serial import serialize_generic_user @@ -384,9 +385,7 @@ class OrganizationSerializer(BaseOrganizationSerializer): allow_null=True, ) defaultCodingAgentIntegrationId = serializers.IntegerField(required=False, allow_null=True) - defaultAutomatedRunStoppingPoint = serializers.ChoiceField( - choices=["code_changes", "open_pr"], required=False - ) + defaultAutomatedRunStoppingPoint = serializers.CharField(required=False) autoOpenPrs = serializers.BooleanField(required=False) autoEnableCodeReview = serializers.BooleanField(required=False) defaultCodeReviewTriggers = serializers.ListField( @@ -434,6 +433,12 @@ def validate_defaultCodingAgentIntegrationId(self, value: int | None) -> int | N raise serializers.ValidationError("Integration does not exist.") return value + def validate_defaultAutomatedRunStoppingPoint(self, value: str) -> str: + organization = self.context["organization"] + if value not in get_valid_automated_run_stopping_points(organization): + raise serializers.ValidationError(f'"{value}" is not a valid choice.') + return value + def validate_sensitiveFields(self, value): if value and not all(value): raise serializers.ValidationError("Empty values are not allowed.") diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 9fcef3270ed674..5b6dfa85cb1216 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -303,6 +303,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-explorer-chat-coding", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the Seer Overview sections for Seat-Based users manager.add("organizations:seer-overview", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Allow root_cause as a valid automated run stopping point and org-level default + manager.add("organizations:root-cause-stopping-point", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable the Seer Wizard and related prompts/links/banners manager.add("organizations:seer-wizard", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the Seer issues view diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index fba791d6413418..f0bf410ef22102 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -67,6 +67,16 @@ class AutofixStoppingPoint(StrEnum): OPEN_PR = "open_pr" +def get_valid_automated_run_stopping_points( + organization: Organization, +) -> set[AutofixStoppingPoint]: + """Return the set of stopping points valid for the given organization.""" + valid = {AutofixStoppingPoint.CODE_CHANGES, AutofixStoppingPoint.OPEN_PR} + if features.has("organizations:root-cause-stopping-point", organization): + valid.add(AutofixStoppingPoint.ROOT_CAUSE) + return valid + + class AutofixRequest(BaseModel): organization_id: int project_id: int @@ -374,12 +384,17 @@ class SeerAutofixSettingsSerializer(serializers.Serializer): required=False, help_text="The tuning setting for the projects.", ) - automatedRunStoppingPoint = serializers.ChoiceField( - choices=[opt.value for opt in AutofixStoppingPoint], + automatedRunStoppingPoint = serializers.CharField( required=False, help_text="The stopping point for the projects.", ) + def validate_automatedRunStoppingPoint(self, value: str) -> str: + organization = self.context["organization"] + if value not in get_valid_automated_run_stopping_points(organization): + raise serializers.ValidationError(f'"{value}" is not a valid choice.') + return value + def validate(self, data): if "autofixAutomationTuning" not in data and "automatedRunStoppingPoint" not in data: raise serializers.ValidationError( @@ -405,6 +420,9 @@ def get_org_default_seer_automation_handoff( stopping_point = organization.get_option( "sentry:default_automated_run_stopping_point", SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT ) + # Guard against stored stopping points that are no longer valid. + if stopping_point not in get_valid_automated_run_stopping_points(organization): + stopping_point = SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT auto_open_prs = organization.get_option("sentry:auto_open_prs", AUTO_OPEN_PRS_DEFAULT) diff --git a/src/sentry/seer/blueprints/api.md b/src/sentry/seer/blueprints/api.md index 2eb4dcd9a0de55..ba9a570e9f90ce 100644 --- a/src/sentry/seer/blueprints/api.md +++ b/src/sentry/seer/blueprints/api.md @@ -27,12 +27,12 @@ Retrieves a paginated list of projects with their autofix automation settings. **Attributes** -| Column | Type | Description | -| ------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- | -| projectId | int | The project ID | -| autofixAutomationTuning | string | The tuning setting for automated autofix. One of: `off`, `medium`, (deprecated values: `super_low`, `low`, `high`, `always`) | -| automatedRunStoppingPoint | string | The stopping point for automated runs. One of: `code_changes`, `open_pr`, (deprecated values: `root_cause`, `solution`) | -| reposCount | int | Number of repositories configured for the project | +| Column | Type | Description | +| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| projectId | int | The project ID | +| autofixAutomationTuning | string | The tuning setting for automated autofix. One of: `off`, `medium`, (deprecated values: `super_low`, `low`, `high`, `always`) | +| automatedRunStoppingPoint | string | The stopping point for automated runs. One of: `code_changes`, `open_pr`, `root_cause` (requires `root-cause-stopping-point` flag), (deprecated values: `solution`) | +| reposCount | int | Number of repositories configured for the project | - Response 200 @@ -61,11 +61,11 @@ Bulk create/update the autofix automation settings for multiple projects in a si **Attributes** -| Column | Type | Required | Description | -| ------------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------ | -| projectIds | list[int] | Yes | List of project IDs to update (min: 1, max: 1000) | -| autofixAutomationTuning | string | No\* | The tuning setting. One of: `off`, `medium`, (deprecated values: `super_low`, `low`, `high`, `always`) | -| automatedRunStoppingPoint | string | No\* | The stopping point. One of: `code_changes`, `open_pr`, (deprecated values: `root_cause`, `solution`) | +| Column | Type | Required | Description | +| ------------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| projectIds | list[int] | Yes | List of project IDs to update (min: 1, max: 1000) | +| autofixAutomationTuning | string | No\* | The tuning setting. One of: `off`, `medium`, (deprecated values: `super_low`, `low`, `high`, `always`) | +| automatedRunStoppingPoint | string | No\* | The stopping point. One of: `code_changes`, `open_pr`, `root_cause` (requires `root-cause-stopping-point` flag), (deprecated values: `solution`) | \* At least one of either `autofixAutomationTuning` or `automatedRunStoppingPoint` must be provided. diff --git a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py index 70991078d39321..399d73bf32481e 100644 --- a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py +++ b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py @@ -246,7 +246,9 @@ def post(self, request: Request, organization: Organization) -> Response: :pparam string organization_id_or_slug: the id or slug of the organization. :auth: required """ - serializer = SeerAutofixSettingsPostSerializer(data=request.data) + serializer = SeerAutofixSettingsPostSerializer( + data=request.data, context={"organization": organization} + ) if not serializer.is_valid(): return Response(serializer.errors, status=400) diff --git a/src/sentry/tasks/seer/autofix.py b/src/sentry/tasks/seer/autofix.py index 1d4ac97d8abcb2..0ab062fa8a2cb9 100644 --- a/src/sentry/tasks/seer/autofix.py +++ b/src/sentry/tasks/seer/autofix.py @@ -28,6 +28,7 @@ get_autofix_state, get_org_default_seer_automation_handoff, get_seer_seat_based_tier_cache_key, + get_valid_automated_run_stopping_points, resolve_repository_ids, ) from sentry.seer.models import SeerProjectPreference @@ -243,8 +244,7 @@ def configure_seer_for_existing_org(organization_id: int) -> None: default_stopping_point, default_handoff = get_org_default_seer_automation_handoff(organization) default_handoff_dict = default_handoff.dict() if default_handoff else None - - valid_stopping_points = {"open_pr", "code_changes"} + valid_stopping_points = get_valid_automated_run_stopping_points(organization) preferences_by_id = bulk_get_project_preferences(organization_id, project_ids) diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index e203d076de857f..97c44bce787745 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -677,6 +677,14 @@ def test_new_orgs_with_options_do_not_get_onboarding_feature_flag(self) -> None: ) assert "onboarding" not in response.data["features"] + def test_invalid_stored_stopping_point_falls_back_to_default(self) -> None: + self.organization.update_option("sentry:default_automated_run_stopping_point", "root_cause") + response = self.get_success_response(self.organization.slug) + assert ( + response.data["defaultAutomatedRunStoppingPoint"] + == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT + ) + @cell_silo_test(cells=cells) class OrganizationUpdateTest(OrganizationDetailsTestBase): @@ -1684,11 +1692,17 @@ def test_default_automated_run_stopping_point_can_be_set(self) -> None: assert response.data["defaultAutomatedRunStoppingPoint"] == choice def test_default_automated_run_stopping_point_rejects_invalid(self) -> None: - for invalid in ("root_cause", "solution", "invalid_point"): + for invalid in ("solution", "invalid_point", "root_cause"): with self.subTest(value=invalid): data = {"defaultAutomatedRunStoppingPoint": invalid} self.get_error_response(self.organization.slug, status_code=400, **data) + def test_default_automated_run_stopping_point_accepts_root_cause_with_flag(self) -> None: + with self.feature("organizations:root-cause-stopping-point"): + data = {"defaultAutomatedRunStoppingPoint": "root_cause"} + response = self.get_success_response(self.organization.slug, **data) + assert response.data["defaultAutomatedRunStoppingPoint"] == "root_cause" + def test_default_coding_agent_integration_id_can_be_cleared(self) -> None: self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 123) data = {"defaultCodingAgentIntegrationId": None} diff --git a/tests/sentry/seer/autofix/test_autofix_utils.py b/tests/sentry/seer/autofix/test_autofix_utils.py index 2f5c8c401d6ed3..58a9697bdab688 100644 --- a/tests/sentry/seer/autofix/test_autofix_utils.py +++ b/tests/sentry/seer/autofix/test_autofix_utils.py @@ -4,7 +4,7 @@ import orjson import pytest -from sentry.constants import DataCategory +from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, DataCategory from sentry.seer.autofix.constants import AutofixStatus from sentry.seer.autofix.trigger import is_issue_eligible_for_seer_automation from sentry.seer.autofix.utils import ( @@ -1307,3 +1307,10 @@ def test_seer_coding_agent_treated_as_no_external_agent(self): stopping_point, handoff = get_org_default_seer_automation_handoff(self.organization) assert stopping_point == "open_pr" assert handoff is None + + def test_invalid_stopping_point_falls_back_to_default(self): + self.organization.update_option( + "sentry:default_automated_run_stopping_point", "invalid_point" + ) + stopping_point, _ = get_org_default_seer_automation_handoff(self.organization) + assert stopping_point == SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT diff --git a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py index 1473e2580c127d..381ae9e7376a88 100644 --- a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py +++ b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py @@ -294,6 +294,29 @@ def test_post_rejects_invalid_stopping_point(self) -> None: ) assert response.status_code == 400 + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_get_project_preferences" + ) + @patch( + "sentry.seer.endpoints.organization_autofix_automation_settings.bulk_set_project_preferences" + ) + def test_post_accepts_root_cause_stopping_point_with_flag( + self, mock_bulk_set_preferences, mock_bulk_get_preferences + ) -> None: + project = self.create_project(organization=self.organization) + mock_bulk_get_preferences.return_value = {} + mock_bulk_set_preferences.return_value = None + + with self.feature("organizations:root-cause-stopping-point"): + response = self.client.post( + self.url, + { + "projectIds": [project.id], + "automatedRunStoppingPoint": "root_cause", + }, + ) + assert response.status_code == 204 + def test_post_rejects_projects_not_in_organization(self) -> None: project = self.create_project(organization=self.organization) other_org = self.create_organization() diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index ed92f065fb2c17..201d437ecf4d6c 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -358,6 +358,26 @@ def test_project_with_invalid_stopping_point_gets_org_default_stopping_point( ) assert prefs_by_project[project.id]["automation_handoff"] == existing_handoff + @patch("sentry.tasks.seer.autofix.bulk_set_project_preferences") + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") + def test_root_cause_stopping_point_preserved_when_valid( + self, mock_bulk_get: MagicMock, mock_bulk_set: MagicMock + ) -> None: + """Project with root_cause stopping point is preserved when root-cause-stopping-point flag is enabled.""" + project = self.create_project(organization=self.organization) + + mock_bulk_get.return_value = { + str(project.id): { + "automated_run_stopping_point": "root_cause", + "automation_handoff": None, + }, + } + + with self.feature("organizations:root-cause-stopping-point"): + configure_seer_for_existing_org(organization_id=self.organization.id) + + mock_bulk_set.assert_not_called() + @patch("sentry.tasks.seer.autofix.bulk_get_project_preferences") def test_raises_on_bulk_get_api_failure(self, mock_bulk_get: MagicMock) -> None: """Test that task raises on bulk GET API failure to trigger retry.""" From a81ef606ce678b0f46e6e4ab088572a0a8e4fda9 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Thu, 2 Apr 2026 13:13:06 -0700 Subject: [PATCH 46/80] fix(hybrid-cloud): Add projectkey-cell-mappings to control silo URL patterns (#112152) Add the missing `api/0/internal/projectkey-cell-mappings/` pattern to the control silo URL inventory. The new internal endpoint introduced in https://github.com/getsentry/sentry/pull/112017 was not added to the generated `controlsiloUrlPatterns.ts`, causing `test_no_missing_urls` to fail. Normally this file is regenerated via `getsentry django generate_controlsilo_urls`, but since that requires a getsentry environment, the pattern is added manually here. Co-authored-by: Claude Opus 4.6 --- static/app/data/controlsiloUrlPatterns.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/data/controlsiloUrlPatterns.ts b/static/app/data/controlsiloUrlPatterns.ts index 9162cb86010278..60d83e3c1cabfe 100644 --- a/static/app/data/controlsiloUrlPatterns.ts +++ b/static/app/data/controlsiloUrlPatterns.ts @@ -167,6 +167,7 @@ export const controlsiloUrlPatterns: RegExp[] = [ new RegExp('^api/0/internal/integration-proxy/$'), new RegExp('^api/0/internal/demo/email-capture/$'), new RegExp('^api/0/internal/org-cell-mappings/$'), + new RegExp('^api/0/internal/projectkey-cell-mappings/$'), new RegExp('^api/0/internal/notifications/registered-templates/$'), new RegExp('^api/0/tempest-ips/$'), new RegExp('^api/0/secret-scanning/github/$'), From 8ae6c200820de13e50ba5821531eb78d475e32aa Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Thu, 2 Apr 2026 16:14:10 -0400 Subject: [PATCH 47/80] fix(pipeline): Add CSP nonce to trampoline inline script (#112149) The trampoline page rendered by the pipeline advancer uses a raw HTML string with an inline script, bypassing Django's template system and its automatic nonce injection via the {% script %} tag. When the CSP policy includes 'strict-dynamic' with a nonce, 'unsafe-inline' is ignored per the CSP spec, causing the inline script to be blocked in production. Pass request.csp_nonce into the script tag's nonce attribute. Also relax the event.source check in useRedirectPopupStep so that synthetic MessageEvents in JSDOM tests (where source is always null) are not incorrectly rejected. --- src/sentry/web/frontend/pipeline_advancer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sentry/web/frontend/pipeline_advancer.py b/src/sentry/web/frontend/pipeline_advancer.py index ffbdef10bebe64..9ad6b168c91699 100644 --- a/src/sentry/web/frontend/pipeline_advancer.py +++ b/src/sentry/web/frontend/pipeline_advancer.py @@ -28,7 +28,7 @@ style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; flex-direction:column;padding:2rem"> -