From 2d296b19e008c98edc8b8e734b141e7d239f4fb8 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 2 Jun 2026 16:48:11 +0000 Subject: [PATCH 01/46] Revert "ci: Run ESLint only on changed files in a PR (#116660)" This reverts commit 5c59d63b09380d53b7da7f0f81bcf6978b40548b. Co-authored-by: ryan953 <187460+ryan953@users.noreply.github.com> --- .github/file-filters.yml | 15 --------------- .github/workflows/frontend.yml | 14 +------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/.github/file-filters.yml b/.github/file-filters.yml index 497ae767c027a2..8285f01b1d0f99 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -39,21 +39,6 @@ mdx_typecheckable: &mdx_typecheckable - added|deleted|modified: 'static/app/components/core/**/*.{ts,tsx}' - added|deleted|modified: 'static/**/__stories__/**/*.{ts,tsx}' -lintable_modified: &lintable_modified - - *sentry_frontend_workflow_file - - *sentry_frontend_snapshots_workflow_file - - added|modified: '**/*.{ts,tsx,js,jsx,mjs}' - - added|modified: 'static/**/*.{less,json,yml,md,mdx}' - - added|modified: '{vercel,tsconfig,biome,package}.json' - -lintable_rules_changed: &lintable_rules_changed - - *sentry_frontend_workflow_file - - *sentry_frontend_snapshots_workflow_file - - added|modified: 'package.json' - - added|modified: '.github/file-filters.yml' - - added|modified: '*.config.ts' - - added|modified: 'static/eslint/**/*.{ts,tsx,js,jsx,mjs}' - # Trigger to apply the 'Scope: Frontend' label to PRs frontend_all: &frontend_all - *sentry_frontend_workflow_file diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 80c6310c737e89..3d17cb526c8751 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -28,11 +28,7 @@ jobs: testable_rules_changed: ${{ steps.changes.outputs.testable_rules_changed }} typecheckable_rules_changed: ${{ steps.changes.outputs.typecheckable_rules_changed }} mdx_typecheckable: ${{ steps.changes.outputs.mdx_typecheckable }} - lintable_modified: ${{ steps.changes.outputs.lintable_modified }} - lintable_modified_files: ${{ steps.changes.outputs.lintable_modified_files }} - lintable_rules_changed: ${{ steps.changes.outputs.lintable_rules_changed }} frontend_all: ${{ steps.changes.outputs.frontend_all }} - frontend_all_files: ${{ steps.changes.outputs.frontend_all_files }} merge_base: ${{ steps.merge_base.outputs.merge_base }} merge_base_strategy: ${{ steps.merge_base.outputs.merge_base_strategy }} steps: @@ -132,15 +128,7 @@ jobs: - name: eslint id: eslint - env: - LINTABLE_RULES_CHANGED: ${{ needs.files-changed.outputs.lintable_rules_changed }} - LINTABLE_MODIFIED_FILES: ${{ needs.files-changed.outputs.lintable_modified_files }} - run: | - if [[ "$GITHUB_EVENT_NAME" == "pull_request" && "$LINTABLE_RULES_CHANGED" == "false" ]]; then - pnpm run lint:js $LINTABLE_MODIFIED_FILES - else - pnpm run lint:js - fi + run: pnpm run lint:js knip: if: needs.files-changed.outputs.frontend_all == 'true' From 85740252685a0d4030affa6f3e717d3cb2500a70 Mon Sep 17 00:00:00 2001 From: Sebastian Zivota Date: Tue, 2 Jun 2026 18:49:47 +0200 Subject: [PATCH 02/46] ref: Remove `relay:measurements-smart-conversion` feature (#116615) Depends on https://github.com/getsentry/relay/pull/6034 and https://github.com/getsentry/sentry-options-automator/pull/8010. Those PRs remove the flag from Relay options-automator, respectively. Once they are deployed we can finally also delete the flag from this repo. Closes INGEST-939. --- src/sentry/features/temporary.py | 2 -- src/sentry/relay/config/__init__.py | 1 - 2 files changed, 3 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 70433181c94d04..e4ca2fe5cfc9ae 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -472,8 +472,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("projects:relay-minidump-uploads", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enables the uploading of playstation attachments to the objectstore. manager.add("projects:relay-playstation-uploads", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) - # Enables smarter measurements -> attributes conversion in Relay (see https://github.com/getsentry/relay/pull/6007) - manager.add("projects:relay-measurements-smart-conversion", ProjectFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable lightweight RCA clustering write path (generate embeddings on new issues) manager.add("organizations:supergroups-lightweight-rca-clustering-write", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) diff --git a/src/sentry/relay/config/__init__.py b/src/sentry/relay/config/__init__.py index 4d0caf7b9ece8a..2d95c8e4f89284 100644 --- a/src/sentry/relay/config/__init__.py +++ b/src/sentry/relay/config/__init__.py @@ -74,7 +74,6 @@ "projects:relay-minidump-attachment-uploads", "projects:relay-minidump-uploads", "projects:relay-playstation-uploads", - "projects:relay-measurements-smart-conversion", ] EXTRACT_METRICS_VERSION = 1 From 30f6d618a0cd1f0814713b5886c3efe2b1b5f2ab Mon Sep 17 00:00:00 2001 From: Leander Rodrigues Date: Tue, 2 Jun 2026 12:50:00 -0400 Subject: [PATCH 03/46] chore(seer-slack): Remove unused flag (#116683) Already removed registrations in https://github.com/getsentry/sentry-options-automator/pull/8034 --- src/sentry/features/temporary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index e4ca2fe5cfc9ae..281c1d4b377ef9 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -284,8 +284,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:root-cause-stopping-point", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable autofix introspection for early stopping of autofix runs manager.add("organizations:seer-autofix-introspection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable Seer Workflows in Slack (released, kept until overrides are removed) - manager.add("organizations:seer-slack-workflows", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Show Seer run ID in Slack notification footers manager.add("organizations:seer-run-id-in-slack", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Gate display of Seer action events in the issue activity timeline From 5f633387ec55231d1e659c3943fe7e69041ec02e Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 2 Jun 2026 09:53:50 -0700 Subject: [PATCH 04/46] fix(data-scrubbing): Stop source field suggestion scroll from crashing (#116653) The data scrubbing source field crashed with `Cannot read properties of undefined (reading 'scrollIntoView')` because arrow-key nav bounded against the full suggestion list while only the first 50 are rendered, so the active index could point past a real DOM node. Scrolls via a callback ref on the active item instead of indexing into `children`, clamps nav to the rendered count, and drops some dead weight in the component (unused refs, `event.persist()`, the `hideCaret` state). Somewhat preparing this component to be rewritten as functional. fixes JAVASCRIPT-39FH its this modal on [the security and privacy page](https://sentry.sentry.io/settings/projects/javascript/security-and-privacy/) image --- .../modals/dataScrubFormModal.tsx | 2 +- .../dataScrubbing/modals/form/sourceField.tsx | 71 ++++++++----------- 2 files changed, 31 insertions(+), 42 deletions(-) diff --git a/static/app/views/settings/components/dataScrubbing/modals/dataScrubFormModal.tsx b/static/app/views/settings/components/dataScrubbing/modals/dataScrubFormModal.tsx index d985363afe58c1..103bc6af62ba75 100644 --- a/static/app/views/settings/components/dataScrubbing/modals/dataScrubFormModal.tsx +++ b/static/app/views/settings/components/dataScrubbing/modals/dataScrubFormModal.tsx @@ -256,7 +256,7 @@ export function DataScrubFormModal({ hintText={t('The dataset targeted by the scrubbing rule')} variant="compact" > - + {sortBy(enabledDatasets).map(value => ( {getDatasetLabelLong(value)} diff --git a/static/app/views/settings/components/dataScrubbing/modals/form/sourceField.tsx b/static/app/views/settings/components/dataScrubbing/modals/form/sourceField.tsx index 2cae36de065432..e2946dd3bcb83c 100644 --- a/static/app/views/settings/components/dataScrubbing/modals/form/sourceField.tsx +++ b/static/app/views/settings/components/dataScrubbing/modals/form/sourceField.tsx @@ -1,4 +1,4 @@ -import {Component, createRef, Fragment} from 'react'; +import {Component, Fragment} from 'react'; import styled from '@emotion/styled'; import {Input} from '@sentry/scraps/input'; @@ -16,6 +16,10 @@ import { import {SourceSuggestionExamples} from './sourceSuggestionExamples'; +// The suggestion list is capped when rendered, so keyboard navigation and +// scrolling must stay within this many items to match what's in the DOM. +const MAX_RENDERED_SUGGESTIONS = 50; + type FieldProps = { 'aria-describedby': string; 'aria-invalid': boolean; @@ -37,7 +41,6 @@ type State = { activeSuggestion: number; fieldValues: Array; help: string; - hideCaret: boolean; showSuggestions: boolean; suggestions: SourceSuggestion[]; }; @@ -48,7 +51,6 @@ export class SourceField extends Component { fieldValues: [], activeSuggestion: 0, showSuggestions: false, - hideCaret: false, help: '', }; @@ -71,9 +73,6 @@ export class SourceField extends Component { } } - selectorField = createRef(); - suggestionList = createRef(); - getAllSuggestions() { return [...this.getValueSuggestions(), ...unarySuggestions, ...binarySuggestions]; } @@ -225,22 +224,6 @@ export class SourceField extends Component { }); } - scrollToSuggestion() { - const {activeSuggestion, hideCaret} = this.state; - - this.suggestionList?.current?.children[activeSuggestion]!.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'start', - }); - - if (!hideCaret) { - this.setState({ - hideCaret: true, - }); - } - } - changeParentValue() { const {onChange} = this.props; const {fieldValues} = this.state; @@ -331,7 +314,6 @@ export class SourceField extends Component { handleClickOutside = () => { this.setState({ showSuggestions: false, - hideCaret: false, }); }; @@ -342,15 +324,12 @@ export class SourceField extends Component { fieldValues, activeSuggestion: 0, showSuggestions: false, - hideCaret: false, }, this.changeParentValue ); }; - handleKeyDown = (_value: string, event: React.KeyboardEvent) => { - event.persist(); - + handleKeyDown = (event: React.KeyboardEvent) => { const {key} = event; const {activeSuggestion, suggestions, showSuggestions} = this.state; @@ -371,19 +350,17 @@ export class SourceField extends Component { if (activeSuggestion === 0) { return; } - this.setState({activeSuggestion: activeSuggestion - 1}, () => { - this.scrollToSuggestion(); - }); + this.setState({activeSuggestion: activeSuggestion - 1}); return; } if (key === 'ArrowDown') { - if (activeSuggestion === suggestions.length - 1) { + const lastRenderedIndex = + Math.min(suggestions.length, MAX_RENDERED_SUGGESTIONS) - 1; + if (activeSuggestion >= lastRenderedIndex) { return; } - this.setState({activeSuggestion: activeSuggestion + 1}, () => { - this.scrollToSuggestion(); - }); + this.setState({activeSuggestion: activeSuggestion + 1}); return; } }; @@ -392,12 +369,20 @@ export class SourceField extends Component { this.toggleSuggestions(true); }; + scrollActiveSuggestionIntoView = (node: HTMLLIElement | null) => { + node?.scrollIntoView?.({ + behavior: 'smooth', + block: 'nearest', + inline: 'start', + }); + }; + render() { const {value, fieldProps} = this.props; - const {showSuggestions, suggestions, activeSuggestion, hideCaret, help} = this.state; + const {showSuggestions, suggestions, activeSuggestion, help} = this.state; return ( - + { onChange={e => this.handleChange(e.target.value)} autoComplete="off" value={value} - onKeyDown={e => this.handleKeyDown(value, e)} + onKeyDown={this.handleKeyDown} onBlur={fieldProps.onBlur} onFocus={this.handleFocus} /> @@ -417,10 +402,15 @@ export class SourceField extends Component { )} {showSuggestions && suggestions.length > 0 && ( - - {suggestions.slice(0, 50).map((suggestion, index) => ( + + {suggestions.slice(0, MAX_RENDERED_SUGGESTIONS).map((suggestion, index) => ( { event.preventDefault(); this.handleClickSuggestionItem(suggestion); @@ -451,10 +441,9 @@ export class SourceField extends Component { } } -const Wrapper = styled('div')<{hideCaret?: boolean}>` +const Wrapper = styled('div')` position: relative; width: 100%; - ${p => p.hideCaret && 'caret-color: transparent;'} `; const StyledInput = styled(Input)` From b319ab14821f7bdfe628de9d2a37c8d621ec20e3 Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Tue, 2 Jun 2026 09:54:58 -0700 Subject: [PATCH 05/46] feat(api): Union response annotations with plugin narrowing + relaxed linter (#116659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of infrastructure that together enable typing endpoints with mixed return shapes (success TypedDict + inline error bodies) without business-logic changes. **Plugin narrowing across union arms** The mypy plugin gains a sibling hook to the body-Any check. When `Response(, ...)` is constructed inside a function returning a union of `Response[...]` arms, the plugin narrows the literal to one arm structurally: ``` Non-empty dict literal → TypedDict-coercion check per arm's T Empty dict literal `{}` → matches dict[K,V] or no-required-keys TypedDict Empty list literal `[]` → matches list[T] arms ``` Exactly one match → returns `Response[that_T]`. Zero (drift) or multiple (ambiguous) → falls back to default. The plugin is **name-agnostic** — no hardcoded TypedDict names. It does for unions what mypy already does in single-target `Response[T]` contexts, which mypy intentionally refuses to extend to unions (bidirectional TypedDict-from-literal inference is single-target only by design). **Linter relaxation: set-subset, not set-equality** The structural linter (#116496) now requires the decorator's typed-T set to be a **subset** of the annotation's typed-T set, not equal. ``` Before (set-equality): ───────────────────── Decorator: {200: inline_sentry_response_serializer("X", T)} Annotation: Response[T] | Response[ErrorT] → mismatch (annotation declares more) After (set-subset): ─────────────────── Same input → linter passes. {T} ⊆ {T, ErrorT}. ``` This lets endpoints enrich their internal annotations (e.g. local error TypedDicts) without forcing decorator migrations that would change the OpenAPI document. Drift in the other direction (decorator declares T that annotation omits) is still caught. **`src/sentry/apidocs/response_types.py` as a shared-shapes module** A small module that holds TypedDict shapes recurring across endpoints. Initial contents: `DetailResponse` (DRF's standard `{"detail": str}` shape). The module is **not** authoritative — endpoints whose error shapes don't match anything here are expected to declare local TypedDicts in their own files. The linter is name-agnostic, treating shared and local TypedDicts identically. **Migration coverage in this PR: 9 of 25 candidate endpoints** Migrated under the new infrastructure: ``` src/sentry/api/endpoints/debug_files.py src/sentry/issues/endpoints/organization_eventid.py src/sentry/issues/endpoints/project_event_details.py src/sentry/integrations/api/endpoints/organization_config_integrations.py src/sentry/integrations/api/endpoints/organization_integrations_index.py src/sentry/preprod/api/endpoints/public/organization_preprod_size_analysis.py src/sentry/preprod/api/endpoints/public/organization_preprod_artifact_install_details.py src/sentry/issues/endpoints/group_events.py (plugin G1 — empty list) src/sentry/replays/endpoints/organization_replay_count.py (plugin G1 — empty dict) ``` Each migrated endpoint's diff is exactly two things: add a `DetailResponse` import, and change the return annotation from `Response` to a union. **The other 16 candidates stay unmigrated** They have body sources that are typed `Any` at the runtime API surface (`serializer.errors` returns DRF `ReturnDict[Any, Any]`, pydantic `resp.dict()` returns `dict[str, Any]`, untyped helper functions), or inline bodies with custom nested shapes that don't match shared TypedDicts. Migrating them requires improving the *source* — typing DRF stubs better, typing pydantic dict() returns, etc. — not changing how the endpoints work. They stay bare-`-> Response` until the source typing catches up. **No runtime changes anywhere.** No `cast()` calls. No `# type: ignore` comments. No `return → raise` conversions. Plugin and linter operate purely at type-check time. **Verification** - Full-tree mypy (8038 files): clean in src/ and tests/ - prek: clean - Structural linter: clean - `make build-api-docs`: byte-identical to master - Plugin tests: 39/39 pass (29 existing + 7 union-narrowing + 3 empty-literal/name-agnostic) - Linter tests: 19/19 pass (16 existing + 3 subset-semantic scenarios) Co-authored-by: Claude Opus 4.7 (1M context) --- src/sentry/api/endpoints/debug_files.py | 6 +- ...heck_response_annotation_matches_schema.py | 20 +- src/sentry/apidocs/response_types.py | 33 +++ .../organization_config_integrations.py | 5 +- .../organization_integrations_index.py | 3 +- src/sentry/issues/endpoints/group_events.py | 5 +- .../issues/endpoints/organization_eventid.py | 5 +- .../issues/endpoints/project_event_details.py | 5 +- ...zation_preprod_artifact_install_details.py | 3 +- .../organization_preprod_size_analysis.py | 3 +- .../endpoints/organization_replay_count.py | 5 +- ...heck_response_annotation_matches_schema.py | 113 ++++++++- tests/tools/mypy_helpers/test_plugin.py | 199 ++++++++++++++++ tools/mypy_helpers/plugin.py | 215 +++++++++++++++--- 14 files changed, 565 insertions(+), 55 deletions(-) create mode 100644 src/sentry/apidocs/response_types.py diff --git a/src/sentry/api/endpoints/debug_files.py b/src/sentry/api/endpoints/debug_files.py index df32895d6d70d4..d166be11d35e15 100644 --- a/src/sentry/api/endpoints/debug_files.py +++ b/src/sentry/api/endpoints/debug_files.py @@ -17,6 +17,8 @@ from symbolic.debuginfo import normalize_debug_id from symbolic.exceptions import SymbolicError +from sentry.apidocs.response_types import DetailResponse + if TYPE_CHECKING: from django_stubs_ext import WithAnnotations @@ -303,7 +305,9 @@ def download(self, debug_file_id, project: Project): }, examples=DebugFileExamples.LIST_PROJECT_DEBUG_FILES, ) - def get(self, request: Request, project: Project) -> Response: + def get( + self, request: Request, project: Project + ) -> Response[list[DebugFileSerializerResponse]] | Response[DetailResponse]: """ Retrieve a list of debug information files for a given project. """ diff --git a/src/sentry/apidocs/_check_response_annotation_matches_schema.py b/src/sentry/apidocs/_check_response_annotation_matches_schema.py index b4d0c74e121e5d..335abb641297c0 100644 --- a/src/sentry/apidocs/_check_response_annotation_matches_schema.py +++ b/src/sentry/apidocs/_check_response_annotation_matches_schema.py @@ -67,9 +67,11 @@ class Mismatch: def __str__(self) -> str: decl = ", ".join(sorted(self.decl)) or "" annot = ", ".join(sorted(self.annot)) or "" + missing = ", ".join(sorted(self.decl - self.annot)) return ( f"{self.path}:{self.line} {self.cls}.{self.method}: " - f"decorator declares {{{decl}}}, annotation declares {{{annot}}}" + f"decorator declares {{{decl}}} but annotation declares {{{annot}}} " + f"(missing from annotation: {{{missing}}})" ) @@ -211,7 +213,15 @@ def check_file(path: Path) -> list[Mismatch]: decl_set = frozenset(_name_of(t) for t in decl_Ts) annot_set = frozenset(_name_of(t) for t in annot_Ts) - if decl_set != annot_set: + # Subset semantics, not strict equality: every typed T declared by + # `inline_sentry_response_serializer` in the decorator MUST appear in + # the annotation (drift caught), but the annotation MAY declare extra + # arms not in the decorator (e.g. local error TypedDicts not exposed + # in OpenAPI via opaque `RESPONSE_*` constants). This preserves + # API-as-today: endpoints that keep `RESPONSE_BAD_REQUEST`-style + # opaque error declarations can still enrich their internal + # annotations without changing the OpenAPI document. + if not decl_set.issubset(annot_set): mismatches.append( Mismatch( path=path, @@ -244,9 +254,11 @@ def main(argv: list[str]) -> int: sys.stdout.write(f"{m}\n") if all_mismatches: sys.stderr.write( - f"\n{len(all_mismatches)} mismatch(es) — the set of `T`s declared by " + f"\n{len(all_mismatches)} mismatch(es) — every `T` declared by " "`inline_sentry_response_serializer(...)` in `@extend_schema` must " - "equal the set of `T`s in the `Response[T]` (or union) annotation.\n", + "appear in the `Response[T]` (or union) annotation. The annotation " + "MAY declare additional arms (e.g. local error TypedDicts) that the " + "decorator does not expose.\n", ) return 1 return 0 diff --git a/src/sentry/apidocs/response_types.py b/src/sentry/apidocs/response_types.py new file mode 100644 index 00000000000000..877d928e1eed83 --- /dev/null +++ b/src/sentry/apidocs/response_types.py @@ -0,0 +1,33 @@ +"""Shared TypedDicts for endpoint Response annotations. + +This module holds TypedDict shapes that recur across multiple endpoints. +It is *not* authoritative — endpoints whose error/response shapes don't +match anything here are expected to declare local TypedDicts in their +own files. The structural linter at +`sentry.apidocs._check_response_annotation_matches_schema` is name-agnostic; +it does not special-case any TypedDict name in this module. + +`DetailResponse` is included because DRF's exception handler renders every +uncaught `APIException` subclass as `{"detail": "..."}` and a non-trivial +number of endpoints return that shape inline. Other shapes can graduate +into this module as they emerge from real usage. + +The module is named `response_types` rather than `types` to avoid shadowing +Python's stdlib `types` module under subprocess tooling (e.g. some prek hooks). +""" + +from __future__ import annotations + +from typing import TypedDict + + +class DetailResponse(TypedDict): + """DRF's standard error-body shape: `{"detail": str}`. + + Use in `Response[T] | Response[DetailResponse]` annotations on endpoints + whose inline error returns are exactly `Response({"detail": "..."}, status=4xx)`. + Endpoints whose error bodies have richer or different shapes should + declare their own local TypedDicts instead. + """ + + detail: str diff --git a/src/sentry/integrations/api/endpoints/organization_config_integrations.py b/src/sentry/integrations/api/endpoints/organization_config_integrations.py index 2d8a7a036c0d6f..22a4a7af685054 100644 --- a/src/sentry/integrations/api/endpoints/organization_config_integrations.py +++ b/src/sentry/integrations/api/endpoints/organization_config_integrations.py @@ -12,6 +12,7 @@ from sentry.apidocs.constants import RESPONSE_BAD_REQUEST from sentry.apidocs.examples.integration_examples import IntegrationExamples from sentry.apidocs.parameters import GlobalParams, IntegrationParams +from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.integrations.api.serializers.models.integration import ( IntegrationProviderResponse, @@ -49,7 +50,9 @@ class OrganizationConfigIntegrationsEndpoint(OrganizationEndpoint): }, examples=IntegrationExamples.ORGANIZATION_CONFIG_INTEGRATIONS, ) - def get(self, request: Request, organization: Organization) -> Response: + def get( + self, request: Request, organization: Organization + ) -> Response[OrganizationConfigIntegrationsEndpointResponse] | Response[DetailResponse]: """ Get integration provider information about all available integrations for an organization. """ diff --git a/src/sentry/integrations/api/endpoints/organization_integrations_index.py b/src/sentry/integrations/api/endpoints/organization_integrations_index.py index cb0260ffaa3acd..5281a568b4230c 100644 --- a/src/sentry/integrations/api/endpoints/organization_integrations_index.py +++ b/src/sentry/integrations/api/endpoints/organization_integrations_index.py @@ -15,6 +15,7 @@ from sentry.api.serializers import serialize from sentry.apidocs.examples.integration_examples import IntegrationExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, IntegrationParams +from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus from sentry.integrations.api.bases.organization_integrations import ( @@ -85,7 +86,7 @@ def get( request: Request, organization_context: RpcUserOrganizationContext, organization: RpcOrganization, - ) -> Response: + ) -> Response[list[OrganizationIntegrationResponse]] | Response[DetailResponse]: """ Lists all the available Integrations for an Organization. """ diff --git a/src/sentry/issues/endpoints/group_events.py b/src/sentry/issues/endpoints/group_events.py index cb022d95ffe184..dfdd658bc19b4c 100644 --- a/src/sentry/issues/endpoints/group_events.py +++ b/src/sentry/issues/endpoints/group_events.py @@ -29,6 +29,7 @@ ) from sentry.apidocs.examples.event_examples import EventExamples from sentry.apidocs.parameters import CursorQueryParam, EventParams, GlobalParams, IssueParams +from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.exceptions import InvalidSearchQuery @@ -86,7 +87,9 @@ class GroupEventsEndpoint(GroupEndpoint): examples=EventExamples.GROUP_EVENTS_SIMPLE, ) @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-group-events"]) - def get(self, request: Request, group: Group) -> Response: + def get( + self, request: Request, group: Group + ) -> Response[list[SimpleEventSerializerResponse]] | Response[DetailResponse]: """ Return a list of error events bound to an issue """ diff --git a/src/sentry/issues/endpoints/organization_eventid.py b/src/sentry/issues/endpoints/organization_eventid.py index c36057cf03a37f..d9b9672caf5470 100644 --- a/src/sentry/issues/endpoints/organization_eventid.py +++ b/src/sentry/issues/endpoints/organization_eventid.py @@ -16,6 +16,7 @@ from sentry.apidocs.constants import RESPONSE_NOT_FOUND from sentry.apidocs.examples.organization_examples import OrganizationExamples from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organization import Organization from sentry.ratelimits.config import RateLimitConfig @@ -62,7 +63,9 @@ class EventIdLookupEndpoint(OrganizationEndpoint): }, examples=OrganizationExamples.EVENT_EXAMPLES, ) - def get(self, request: Request, organization: Organization, event_id: str) -> Response: + def get( + self, request: Request, organization: Organization, event_id: str + ) -> Response[EventIdLookupResponse] | Response[DetailResponse]: """ This resolves an event ID to the project slug and internal issue ID and internal event ID. """ diff --git a/src/sentry/issues/endpoints/project_event_details.py b/src/sentry/issues/endpoints/project_event_details.py index c781701c78ddc5..845c897d46bacd 100644 --- a/src/sentry/issues/endpoints/project_event_details.py +++ b/src/sentry/issues/endpoints/project_event_details.py @@ -23,6 +23,7 @@ ) from sentry.apidocs.examples.event_examples import EventExamples from sentry.apidocs.parameters import EventParams, GlobalParams +from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.exceptions import InvalidParams from sentry.models.project import Project @@ -136,7 +137,9 @@ class ProjectEventDetailsEndpoint(ProjectEndpoint): }, examples=EventExamples.GROUP_EVENT_DETAILS, ) - def get(self, request: Request, project: Project, event_id: str) -> Response: + def get( + self, request: Request, project: Project, event_id: str + ) -> Response[GroupEventDetailsResponse] | Response[DetailResponse]: """ Return details on an individual event. """ diff --git a/src/sentry/preprod/api/endpoints/public/organization_preprod_artifact_install_details.py b/src/sentry/preprod/api/endpoints/public/organization_preprod_artifact_install_details.py index 5b53db2512f9a1..2d81c391928b9d 100644 --- a/src/sentry/preprod/api/endpoints/public/organization_preprod_artifact_install_details.py +++ b/src/sentry/preprod/api/endpoints/public/organization_preprod_artifact_install_details.py @@ -11,6 +11,7 @@ from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.preprod_examples import PreprodExamples from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.organization import Organization from sentry.preprod.api.models.public.installable_builds import ( @@ -62,7 +63,7 @@ def get( request: Request, organization: Organization, artifact_id: str, - ) -> Response: + ) -> Response[InstallInfoResponseDict] | Response[DetailResponse]: """ Retrieve install info for a given artifact. diff --git a/src/sentry/preprod/api/endpoints/public/organization_preprod_size_analysis.py b/src/sentry/preprod/api/endpoints/public/organization_preprod_size_analysis.py index 233060765933fd..c8cff316b1a7e6 100644 --- a/src/sentry/preprod/api/endpoints/public/organization_preprod_size_analysis.py +++ b/src/sentry/preprod/api/endpoints/public/organization_preprod_size_analysis.py @@ -16,6 +16,7 @@ from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.preprod_examples import PreprodExamples from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.files.file import File from sentry.models.organization import Organization @@ -103,7 +104,7 @@ def get( request: Request, organization: Organization, artifact_id: str, - ) -> Response: + ) -> Response[SizeAnalysisResponseDict] | Response[DetailResponse]: """ Retrieve size analysis results for a given artifact. diff --git a/src/sentry/replays/endpoints/organization_replay_count.py b/src/sentry/replays/endpoints/organization_replay_count.py index 976bf0eff530c5..76c805461b4aa3 100644 --- a/src/sentry/replays/endpoints/organization_replay_count.py +++ b/src/sentry/replays/endpoints/organization_replay_count.py @@ -15,6 +15,7 @@ from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import GlobalParams, OrganizationParams, VisibilityParams +from sentry.apidocs.response_types import DetailResponse from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.exceptions import InvalidSearchQuery from sentry.models.organization import Organization @@ -81,7 +82,9 @@ class OrganizationReplayCountEndpoint(OrganizationEventsEndpointBase): 403: RESPONSE_FORBIDDEN, }, ) - def get(self, request: Request, organization: Organization) -> Response: + def get( + self, request: Request, organization: Organization + ) -> Response[dict[int, int]] | Response[DetailResponse]: """Return a count of replays for a list of issue or transaction IDs. The `query` parameter is required. It is a search query that includes exactly one of `issue.id`, `transaction`, or `replay_id` (string or list of strings). diff --git a/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py b/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py index 43a222d7b692ec..4a7d69ff518e84 100644 --- a/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py +++ b/tests/sentry/apidocs/test_check_response_annotation_matches_schema.py @@ -191,8 +191,12 @@ def get(self) -> Response[FooResponse]: assert mismatches[0].annot == frozenset({"FooResponse"}) -def test_annotation_has_extra_T_not_in_decorator_fires() -> None: - """If the annotation union declares a `T` that the decorator doesn't, fail.""" +def test_annotation_has_extra_T_not_in_decorator_passes() -> None: + """Under the subset linter, the annotation MAY declare arms the decorator + doesn't. These are internal-only type contracts (e.g. local error + TypedDicts not exposed in OpenAPI via inline_sentry_response_serializer). + The decorator's typed-T set still has to be a subset of the annotation's, + but the annotation is free to declare more.""" source = """ from typing import TypedDict from drf_spectacular.utils import extend_schema @@ -202,20 +206,17 @@ def test_annotation_has_extra_T_not_in_decorator_fires() -> None: class FooResponse(TypedDict): x: int -class GhostResponse(TypedDict): - y: int +class LocalErrorBody(TypedDict): + error: str class FooEndpoint: @extend_schema( responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, ) - def get(self) -> Response[FooResponse] | Response[GhostResponse]: + def get(self) -> Response[FooResponse] | Response[LocalErrorBody]: return Response({"x": 1}) """ - mismatches = _run(source) - assert len(mismatches) == 1 - assert mismatches[0].decl == frozenset({"FooResponse"}) - assert mismatches[0].annot == frozenset({"FooResponse", "GhostResponse"}) + assert _run(source) == [] def test_multi_2xx_decorator_with_union_annotation() -> None: @@ -417,3 +418,97 @@ def get(self) -> Response[FooResponse]: return Response({"x": 1}) """ assert _run(source) == [] + + +def test_decorator_opaque_RESPONSE_constants_with_annotation_error_arm_passes() -> None: + """API-as-today scenario: decorator declares only the 200 schema via + `inline_sentry_response_serializer` and uses opaque `RESPONSE_*` constants + for errors. Annotation declares the full union including a local error + TypedDict. Under subset semantics this passes — the annotation is + internal-only enrichment that doesn't change the published OpenAPI.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +RESPONSE_BAD_REQUEST = OpenApiResponse(description="Bad Request") + +class FooResponse(TypedDict): + x: int + +class FooErrorBody(TypedDict): + detail: str + +class FooEndpoint: + @extend_schema( + responses={ + 200: inline_sentry_response_serializer("Foo", FooResponse), + 400: RESPONSE_BAD_REQUEST, + }, + ) + def get(self) -> Response[FooResponse] | Response[FooErrorBody]: + return Response({"x": 1}) +""" + assert _run(source) == [] + + +def test_decorator_typed_error_must_appear_in_annotation() -> None: + """If the decorator declares a typed error arm via + `inline_sentry_response_serializer`, the annotation MUST include that T. + Drift in this direction is still caught.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class TypedErrorBody(TypedDict): + detail: str + +class FooEndpoint: + @extend_schema( + responses={ + 200: inline_sentry_response_serializer("Foo", FooResponse), + 400: inline_sentry_response_serializer("Err", TypedErrorBody), + }, + ) + def get(self) -> Response[FooResponse]: + return Response({"x": 1}) +""" + mismatches = _run(source) + assert len(mismatches) == 1 + assert mismatches[0].decl == frozenset({"FooResponse", "TypedErrorBody"}) + assert mismatches[0].annot == frozenset({"FooResponse"}) + + +def test_linter_is_name_agnostic_about_typeddicts() -> None: + """The linter must not special-case any TypedDict name (no DetailResponse + skip, no shape-based heuristics). It compares the sets of names as-is.""" + source = """ +from typing import TypedDict +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from sentry.apidocs.utils import inline_sentry_response_serializer + +class FooResponse(TypedDict): + x: int + +class DetailResponse(TypedDict): + detail: str + +class FooEndpoint: + @extend_schema( + responses={200: inline_sentry_response_serializer("Foo", FooResponse)}, + ) + def get(self) -> Response[FooResponse] | Response[DetailResponse]: + return Response({"x": 1}) +""" + # decorator set = {FooResponse}, annotation set = {FooResponse, DetailResponse} + # {FooResponse} ⊆ {FooResponse, DetailResponse} → passes + # Critically: the linter passes NOT because it special-cases DetailResponse, + # but because the subset rule accepts any extra annotation arm. + assert _run(source) == [] diff --git a/tests/tools/mypy_helpers/test_plugin.py b/tests/tools/mypy_helpers/test_plugin.py index 73908c157e44ec..811088d765b1b0 100644 --- a/tests/tools/mypy_helpers/test_plugin.py +++ b/tests/tools/mypy_helpers/test_plugin.py @@ -517,3 +517,202 @@ async def view() -> Response[Shape]: """ ret, out = call_mypy(src) assert ret == 0, out + + +def test_response_union_dict_literal_narrows_to_typeddict_arm() -> None: + """When the return is `Response[A] | Response[B]` (a union of TypedDicts), + a dict-literal body that matches exactly one arm must narrow. mypy doesn't + do this natively in union contexts — the plugin restores it.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class DetailResponse(TypedDict): + detail: str + +def typed() -> FooResponse: + return {"x": 1} + +def view() -> Response[FooResponse] | Response[DetailResponse]: + return Response({"detail": "Not found"}, status=404) + +def view_success() -> Response[FooResponse] | Response[DetailResponse]: + return Response(typed()) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_union_dict_literal_wrong_shape_errors() -> None: + """Plugin only narrows when exactly one arm accepts. A dict literal that + matches no arm must still surface as a mypy error.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class DetailResponse(TypedDict): + detail: str + +def view() -> Response[FooResponse] | Response[DetailResponse]: + return Response({"wrong": "shape"}, status=400) +""" + ret, out = call_mypy(src) + assert ret, out + assert "Incompatible return value type" in out + + +def test_response_union_value_type_mismatch_errors() -> None: + """`{"detail": 42}` is dict[str, int], not a valid `DetailResponse` + (whose declared `detail: str`). Plugin must NOT narrow.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class DetailResponse(TypedDict): + detail: str + +def view() -> Response[FooResponse] | Response[DetailResponse]: + return Response({"detail": 42}, status=400) +""" + ret, out = call_mypy(src) + assert ret, out + + +def test_response_union_extra_key_rejects() -> None: + """A dict literal with extra keys beyond the TypedDict's fields does NOT + satisfy the TypedDict — plugin must NOT narrow it.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class DetailResponse(TypedDict): + detail: str + +def view() -> Response[FooResponse] | Response[DetailResponse]: + return Response({"detail": "x", "extra": "key"}, status=400) +""" + ret, out = call_mypy(src) + assert ret, out + + +def test_response_union_single_arm_unaffected() -> None: + """Single-armed `Response[T]` is mypy's native bidirectional path. Plugin + narrowing must not interfere.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class DetailResponse(TypedDict): + detail: str + +def view() -> Response[DetailResponse]: + return Response({"detail": "x"}, status=400) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_union_no_typeddict_arms_unaffected() -> None: + """If no union arm has a TypedDict T, plugin must not interfere.""" + src = """\ +from rest_framework.response import Response + +def view() -> Response[int] | Response[str]: + return Response(42) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_union_non_literal_body_unaffected() -> None: + """Narrowing only fires on literal bodies. Variable/function-call bodies + use mypy's standard flow — success arm matches via standard inference.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class DetailResponse(TypedDict): + detail: str + +def typed() -> FooResponse: + return {"x": 1} + +def view() -> Response[FooResponse] | Response[DetailResponse]: + return Response(typed()) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_union_empty_list_narrows_to_list_arm() -> None: + """`Response([])` should match any `Response[list[T]]` arm — empty list + inhabits any element type. mypy infers `list[Never]` for `[]`, which + doesn't match invariant `list[T]` without plugin help.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class DetailResponse(TypedDict): + detail: str + +def view() -> Response[list[FooResponse]] | Response[DetailResponse]: + return Response([], status=200) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_union_empty_dict_narrows_to_dict_arm() -> None: + """`Response({})` should match `Response[dict[K, V]]` arms — empty dict + inhabits any dict. mypy infers `dict[Never, Never]` for `{}`.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class DetailResponse(TypedDict): + detail: str + +def view() -> Response[dict[int, int]] | Response[DetailResponse]: + return Response({}, status=200) +""" + ret, out = call_mypy(src) + assert ret == 0, out + + +def test_response_union_name_agnostic_local_typeddict() -> None: + """The plugin must narrow against ANY TypedDict arm — including locally- + declared ones in the same file. It is NOT hardcoded to recognize specific + names like `DetailResponse`.""" + src = """\ +from typing import TypedDict +from rest_framework.response import Response + +class FooResponse(TypedDict): + x: int + +class StatsPeriodErrorResponse(TypedDict): + error: dict[str, str] + +def view() -> Response[FooResponse] | Response[StatsPeriodErrorResponse]: + return Response({"error": {"period": "invalid"}}, status=400) +""" + ret, out = call_mypy(src) + assert ret == 0, out diff --git a/tools/mypy_helpers/plugin.py b/tools/mypy_helpers/plugin.py index 1ece1cfd77e813..e9ef2f06e3c3eb 100644 --- a/tools/mypy_helpers/plugin.py +++ b/tools/mypy_helpers/plugin.py @@ -2,11 +2,12 @@ import functools from collections.abc import Callable +from typing import Any from mypy.build import PRI_MYPY from mypy.errorcodes import ATTR_DEFINED from mypy.messages import format_type -from mypy.nodes import ARG_POS, MypyFile, TypeInfo +from mypy.nodes import ARG_POS, DictExpr, ListExpr, MypyFile, StrExpr, TypeInfo from mypy.plugin import ( AttributeContext, ClassDefContext, @@ -19,6 +20,7 @@ ) from mypy.plugins.common import add_attribute_to_class from mypy.subtypes import find_member +from mypy.subtypes import is_subtype as _is_subtype from mypy.typeanal import make_optional_type from mypy.types import ( AnyType, @@ -27,8 +29,10 @@ Instance, NoneType, Type, + TypedDictType, TypeOfAny, UnionType, + get_proper_type, ) @@ -188,6 +192,166 @@ def _lazy_service_wrapper_attribute(ctx: AttributeContext, *, attr: str) -> Type return member +_RESPONSE_FULLNAME = "rest_framework.response.Response" +_ASYNC_WRAPPERS = frozenset( + { + "typing.Coroutine", + "typing.Awaitable", + "typing.AsyncGenerator", + "typing.AsyncIterator", + "typing.AsyncIterable", + } +) + + +def _unwrap_response_instances_from_return(expected: Type) -> list[Instance]: + """From a (possibly async-wrapped, possibly union) return type, collect every + `Response[...]` Instance for inspection. Shared by the body-Any check and + the dict/list-literal narrowing hook so both see the enclosing return type + the same way. + """ + out: list[Instance] = [] + pending: list[Type] = [expected] + while pending: + t = get_proper_type(pending.pop()) + if isinstance(t, UnionType): + pending.extend(t.items) + elif isinstance(t, Instance): + if t.type.fullname in _ASYNC_WRAPPERS and t.args: + pending.extend(t.args) + else: + out.append(t) + return out + + +def _dict_literal_matches_typeddict( + body: DictExpr, + td: TypedDictType, + expr_checker: Any, +) -> bool: + """True if `body` (a non-empty dict literal) structurally satisfies `td`. + + Required keys all present, no unknown extras, value types are mypy-subtypes + of declared field types. We re-check shape here rather than routing through + mypy's `check_typeddict_call_with_dict` because that method emits errors + directly on the call site — the plugin needs to probe silently across + union arms. + """ + literal_keys: set[str] = set() + literal_items: dict[str, Type] = {} + for k_expr, v_expr in body.items: + if not isinstance(k_expr, StrExpr): + return False + literal_keys.add(k_expr.value) + try: + literal_items[k_expr.value] = expr_checker.accept(v_expr) + except Exception: + return False + if not td.required_keys.issubset(literal_keys): + return False + if literal_keys - set(td.items.keys()): + return False + for key, value_type in literal_items.items(): + declared = td.items.get(key) + if declared is None: + return False + if not _is_subtype(value_type, declared): + return False + return True + + +def _empty_dict_matches_arm(arm_T: Type) -> bool: + """True if `Response({})` satisfies `Response[arm_T]`. + + Matches `dict[K, V]` for any K, V (empty dict inhabits any dict), and + TypedDicts with no required keys (e.g. `total=False` shapes). + """ + arm_T = get_proper_type(arm_T) + if isinstance(arm_T, TypedDictType): + return not arm_T.required_keys + if isinstance(arm_T, Instance) and arm_T.type.fullname == "builtins.dict": + return True + return False + + +def _empty_list_matches_arm(arm_T: Type) -> bool: + """True if `Response([])` satisfies `Response[arm_T]` — any `list[X]` arm. + + Empty list inhabits any `list[X]` because there are no elements that + could violate `X`. + """ + arm_T = get_proper_type(arm_T) + if isinstance(arm_T, Instance) and arm_T.type.fullname == "builtins.list": + return True + return False + + +def _narrow_response_literal_in_union(ctx: FunctionContext) -> Type: + """Narrow `Response(, ...)` to a matching arm of the enclosing + function's union return type. + + Mypy already narrows when the return type is a single `Response[X]`. When + the return is a union of `Response[...]` arms, mypy gives up bidirectional + inference and infers the body as the broad type of the literal — which + doesn't match any specific arm because `Response[T]` is invariant. This + hook restores the expected narrowing by inspecting each arm. + + Handles three literal shapes: + - non-empty `DictExpr` → TypedDict-coercion check against TypedDict arms + - empty `DictExpr` `{}` → matches `dict[K, V]` arms or no-required-keys + TypedDict arms + - empty `ListExpr` `[]` → matches `list[T]` arms + + Returns `Response[that_T]` when exactly one arm accepts the literal. Zero + or multiple matches → returns default (mypy errors). Non-literal bodies + are untouched. Name-agnostic: no hardcoded TypedDict names. + """ + if not ctx.args or not ctx.args[0]: + return ctx.default_return_type + body_expr = ctx.args[0][0] + # Identify which literal we're dealing with. + is_empty_dict = isinstance(body_expr, DictExpr) and not body_expr.items + is_nonempty_dict = isinstance(body_expr, DictExpr) and bool(body_expr.items) + is_empty_list = isinstance(body_expr, ListExpr) and not body_expr.items + if not (is_empty_dict or is_nonempty_dict or is_empty_list): + return ctx.default_return_type + + arg_name = ctx.arg_names[0][0] if ctx.arg_names and ctx.arg_names[0] else None + if arg_name not in (None, "data"): + return ctx.default_return_type + + chk = ctx.api.expr_checker.chk # type: ignore[attr-defined] + if not getattr(chk, "return_types", None): + return ctx.default_return_type + + response_arms: list[Instance] = [ + inst + for inst in _unwrap_response_instances_from_return(chk.return_types[-1]) + if inst.type.fullname == _RESPONSE_FULLNAME and inst.args + ] + if not response_arms: + return ctx.default_return_type + + expr_checker = ctx.api.expr_checker # type: ignore[attr-defined] + matching: list[Instance] = [] + for inst in response_arms: + T_arg = get_proper_type(inst.args[0]) + if is_nonempty_dict and isinstance(T_arg, TypedDictType): + assert isinstance(body_expr, DictExpr) + if _dict_literal_matches_typeddict(body_expr, T_arg, expr_checker): + matching.append(inst) + elif is_empty_dict and _empty_dict_matches_arm(T_arg): + matching.append(inst) + elif is_empty_list and _empty_list_matches_arm(T_arg): + matching.append(inst) + + if len(matching) != 1: + # Zero matches (real drift) or multiple (ambiguous) — fall back to + # default and let mypy emit its standard error. + return ctx.default_return_type + return matching[0] + + def _check_response_body_not_any(ctx: FunctionContext) -> Type: """Hard-error when `Response[T](body)` is constructed in a context that expects `T = ` but `body` evaluates to `Any`. @@ -219,39 +383,13 @@ def _check_response_body_not_any(ctx: FunctionContext) -> Type: return ctx.default_return_type # Inspect the surrounding type-checker frame for the expected return type. - # `chk.return_types` is the stack of expected return types for enclosing - # functions. The innermost frame is the function containing this call. + # Async wrappers (Coroutine/Awaitable/etc.) and union arms are unwrapped + # by `_unwrap_response_instances_from_return`. chk = ctx.api.expr_checker.chk # type: ignore[attr-defined] if not getattr(chk, "return_types", None): return ctx.default_return_type - expected = chk.return_types[-1] - # Strip Awaitable/Coroutine wrappers (async views return - # `Coroutine[Any, Any, Response[T]]`) and union arms. - expected_instances: list[Instance] = [] - pending: list[Type] = [expected] - _ASYNC_WRAPPERS = frozenset( - { - "typing.Coroutine", - "typing.Awaitable", - "typing.AsyncGenerator", - "typing.AsyncIterator", - "typing.AsyncIterable", - } - ) - while pending: - t = pending.pop() - if isinstance(t, UnionType): - pending.extend(t.items) - elif isinstance(t, Instance): - if t.type.fullname in _ASYNC_WRAPPERS and t.args: - # Coroutine[Y, S, R] → return type R is the last type arg. - # Awaitable/AsyncGenerator/etc. — recurse into all args, the - # `Response[T]` we care about will surface from whichever slot. - pending.extend(t.args) - else: - expected_instances.append(t) - for inst in expected_instances: - if inst.type.fullname != "rest_framework.response.Response": + for inst in _unwrap_response_instances_from_return(chk.return_types[-1]): + if inst.type.fullname != _RESPONSE_FULLNAME: continue if not inst.args: continue @@ -267,10 +405,21 @@ def _check_response_body_not_any(ctx: FunctionContext) -> Type: return ctx.default_return_type +def _dispatch_response_hook(ctx: FunctionContext) -> Type: + """Single Response() construction hook. Tries literal-narrowing first + (covers dict literals + empty literals); if that doesn't apply, falls + through to the body-Any check. + """ + narrowed = _narrow_response_literal_in_union(ctx) + if narrowed is not ctx.default_return_type: + return narrowed + return _check_response_body_not_any(ctx) + + class SentryMypyPlugin(Plugin): def get_function_hook(self, fullname: str) -> Callable[[FunctionContext], Type] | None: - if fullname == "rest_framework.response.Response": - return _check_response_body_not_any + if fullname == _RESPONSE_FULLNAME: + return _dispatch_response_hook return None def get_function_signature_hook( From 93d1b847cf78eabfe68d36eb3d2c6c6a9eca0c67 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 2 Jun 2026 10:00:46 -0700 Subject: [PATCH 06/46] chore: Rollout semver-ordering-with-build-code (#116622) --- .../api/endpoints/organization_releases.py | 25 ++------- src/sentry/api/helpers/group_index/update.py | 11 +--- src/sentry/features/temporary.py | 2 - .../endpoints/test_organization_releases.py | 41 +------------- tests/sentry/api/helpers/test_group_index.py | 54 ++----------------- 5 files changed, 13 insertions(+), 120 deletions(-) diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index 0af74a8a6fa609..b276652e0e87b6 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from rest_framework.serializers import ListField -from sentry import analytics, features, release_health +from sentry import analytics, release_health from sentry.analytics.events.release_created import ReleaseCreatedEvent from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import ReleaseAnalyticsMixin, cell_silo_endpoint @@ -408,17 +408,9 @@ def __get_new(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - order_by_build_code = features.has( - "organizations:semver-ordering-with-build-code", organization - ) - - queryset = queryset.annotate_prerelease_column() - if order_by_build_code: - queryset = queryset.annotate_build_code_column() + queryset = queryset.annotate_prerelease_column().annotate_build_code_column() - semver_cols = ( - Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS - ) + semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE order_by = [F(col).desc(nulls_last=True) for col in semver_cols] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken # when we filter by status, so when we fix that we should also consider the best way to @@ -591,16 +583,9 @@ def __get_old(self, request: Request, organization: Organization) -> Response: queryset = queryset.filter(build_number__isnull=False).order_by("-build_number") paginator_kwargs["order_by"] = "-build_number" elif sort == "semver": - order_by_build_code = features.has( - "organizations:semver-ordering-with-build-code", organization - ) - queryset = queryset.annotate_prerelease_column() - if order_by_build_code: - queryset = queryset.annotate_build_code_column() + queryset = queryset.annotate_prerelease_column().annotate_build_code_column() - semver_cols = ( - Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS - ) + semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE order_by = [F(col).desc(nulls_last=True) for col in semver_cols] # TODO: Adding this extra sort order breaks index usage. Index usage is already broken # when we filter by status, so when we fix that we should also consider the best way to diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 2b2c0787d4ae0c..4d188cbb663606 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -843,22 +843,15 @@ def greatest_semver_release(project: Project) -> Release | None: def get_semver_releases(project: Project) -> QuerySet[Release]: - order_by_build_code = features.has( - "organizations:semver-ordering-with-build-code", project.organization - ) - - semver_cols = ( - Release.SEMVER_COLS_WITH_BUILD_CODE if order_by_build_code else Release.SEMVER_COLS - ) + semver_cols = Release.SEMVER_COLS_WITH_BUILD_CODE qs = ( Release.objects.filter(projects=project, organization_id=project.organization_id) .filter(Q(status=ReleaseStatus.OPEN) | Q(status=None)) .filter_to_semver() # type: ignore[attr-defined] .annotate_prerelease_column() + .annotate_build_code_column() ) - if order_by_build_code: - qs = qs.annotate_build_code_column() return qs.order_by(*[f"-{col}" for col in semver_cols]) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 281c1d4b377ef9..b49cff364d5f8c 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -240,8 +240,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:events-use-replays-dataset", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable using the events api with a sql interface manager.add("organizations:events-sql-grammar-api", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) - # Add build code and build number to semver ordering - manager.add("organizations:semver-ordering-with-build-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable detecting SDK crashes during event processing manager.add("organizations:sdk-crash-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable Seer PR code review for GitHub Enterprise Server organizations diff --git a/tests/sentry/api/endpoints/test_organization_releases.py b/tests/sentry/api/endpoints/test_organization_releases.py index f7352cfea6817f..8ae77f8f9694b5 100644 --- a/tests/sentry/api/endpoints/test_organization_releases.py +++ b/tests/sentry/api/endpoints/test_organization_releases.py @@ -248,44 +248,6 @@ def test_release_list_order_by_build_number(self) -> None: response = self.get_success_response(self.organization.slug, sort="build") self.assert_expected_versions(response, [release_1, release_3, release_2]) - def test_release_list_order_by_semver(self) -> None: - self.login_as(user=self.user) - release_1 = self.create_release(version="test@2.2") - release_2 = self.create_release(version="test@10.0+1000") - release_3 = self.create_release(version="test@2.2-alpha") - release_4 = self.create_release(version="test@2.2.3") - release_5 = self.create_release(version="test@2.20.3") - release_6 = self.create_release(version="test@2.20.3.3") - release_7 = self.create_release(version="test@10.0+998") - release_8 = self.create_release(version="test@some_thing") - release_9 = self.create_release(version="random_junk") - release_10 = self.create_release(version="test@10.0+x22") - release_11 = self.create_release(version="test@10.0+a23") - release_12 = self.create_release(version="test@10.0") - release_13 = self.create_release(version="test@10.0-abc") - release_14 = self.create_release(version="test@10.0+999") - - response = self.get_success_response(self.organization.slug, sort="semver") - - # without build code ordering, tiebreaker is date_added - expected_order = [ - release_14, # test@10.0+999 - release_12, # test@10.0 - release_11, # test@10.0+a23 - release_10, # test@10.0+x22 - release_7, # test@10.0+998 - release_2, # test@10.0+1000 - release_13, # test@10.0-abc - release_6, # test@2.20.3.3 - release_5, # test@2.20.3 - release_4, # test@2.2.3 - release_1, # test@2.2 - release_3, # test@2.2-alpha - release_9, # random_junk - release_8, # test@some_thing - ] - self.assert_expected_versions(response, expected_order) - def test_release_list_order_by_semver_with_build_code(self) -> None: self.login_as(user=self.user) @@ -304,8 +266,7 @@ def test_release_list_order_by_semver_with_build_code(self) -> None: release_13 = self.create_release(version="test@10.0-abc") release_14 = self.create_release(version="test@10.0+999") - with self.feature("organizations:semver-ordering-with-build-code"): - response = self.get_success_response(self.organization.slug, sort="semver") + response = self.get_success_response(self.organization.slug, sort="semver") expected_order = [ release_10, # test@10.0+x22 diff --git a/tests/sentry/api/helpers/test_group_index.py b/tests/sentry/api/helpers/test_group_index.py index f55bda0b41d115..7c88124cf47b3c 100644 --- a/tests/sentry/api/helpers/test_group_index.py +++ b/tests/sentry/api/helpers/test_group_index.py @@ -1249,48 +1249,6 @@ def test_delete_groups_deletes_seer_records_by_hash( class GetSemverReleasesTest(TestCase): - def test_greatest_semver_releases(self) -> None: - """Test get_semver_releases orders releases by semver.""" - release_1 = self.create_release(version="test@2.2", project=self.project) - release_2 = self.create_release(version="test@10.0+1000", project=self.project) - release_3 = self.create_release(version="test@2.2-alpha", project=self.project) - release_4 = self.create_release(version="test@2.2.3", project=self.project) - release_5 = self.create_release(version="test@2.20.3", project=self.project) - release_6 = self.create_release(version="test@2.20.3.3", project=self.project) - release_7 = self.create_release(version="test@10.0+998", project=self.project) - release_8 = self.create_release(version="test@10.0+x22", project=self.project) - release_9 = self.create_release(version="test@10.0+a23", project=self.project) - release_10 = self.create_release(version="test@10.0", project=self.project) - release_11 = self.create_release(version="test@10.0-abc", project=self.project) - release_12 = self.create_release(version="test@10.0+999", project=self.project) - # Non-semver releases that will be filtered out by filter_to_semver() - self.create_release(version="test@some_thing", project=self.project) - self.create_release(version="random_junk", project=self.project) - - releases = list(get_semver_releases(self.project)) - - # Without build code ordering, 10.0 releases (same semver, different build codes) - # are not in deterministic order. Just verify they're grouped at the top. - all_10_releases = { - release_2, - release_7, - release_8, - release_9, - release_10, - release_11, - release_12, - } - - assert len(releases) == 12 - assert set(releases[:7]) == all_10_releases - assert releases[7:] == [ - release_6, - release_5, - release_4, - release_1, - release_3, - ] - def test_greatest_semver_releases_with_build_code(self) -> None: """Test get_semver_releases orders releases by semver and build code.""" release_1 = self.create_release(version="test@2.2", project=self.project) @@ -1309,8 +1267,7 @@ def test_greatest_semver_releases_with_build_code(self) -> None: self.create_release(version="test@some_thing", project=self.project) self.create_release(version="random_junk", project=self.project) - with self.feature("organizations:semver-ordering-with-build-code"): - releases = list(get_semver_releases(self.project)) + releases = list(get_semver_releases(self.project)) expected_order = [ release_8, # test@10.0+x22 @@ -1348,11 +1305,10 @@ def test_greatest_semver_release_with_build_code(self) -> None: ) self.create_release(version="test@1.0+99", project=self.project) - with self.feature("organizations:semver-ordering-with-build-code"): - greatest = greatest_semver_release(self.project) - assert greatest is not None - assert greatest.id == release_with_highest_build.id - assert greatest.version == "test@1.0+100" + greatest = greatest_semver_release(self.project) + assert greatest is not None + assert greatest.id == release_with_highest_build.id + assert greatest.version == "test@1.0+100" class TestHandleReleases(TestCase): From 32ac347bfc08b063bb6e311393b5c83005264482 Mon Sep 17 00:00:00 2001 From: Enoch Tang Date: Tue, 2 Jun 2026 13:03:28 -0400 Subject: [PATCH 07/46] feat: Add KAFKA_TOPIC_CONSUMER_CONFIG for per-topic consumer config (#116611) Refs: STREAM-1062. Adds a KAFKA_TOPIC_CONSUMER_CONFIG setting (keyed by Topic enum value, empty default) and merges it onto the consumer config in `get_kafka_consumer_cluster_options`, after the cluster config and before explicit override_params. This moves per-consumer client overrides off cluster names (region-specific) onto topic names (region-stable), so they survive the KAFKA_CLUSTERS rebuild in STREAM-1058. No behavior change here: the default is empty, so consumer configs are unchanged until SaaS seeds it. --- src/sentry/conf/server.py | 6 +++ src/sentry/consumers/__init__.py | 12 ++++- src/sentry/utils/kafka_config.py | 10 +++- tests/sentry/consumers/test_run.py | 33 ++++++++++++- tests/sentry/utils/test_kafka_config.py | 64 +++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index eeff588061f873..37feae7f93f9a9 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2637,6 +2637,12 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: KAFKA_TOPIC_OVERRIDES: Mapping[str, str] = {} +# Per-topic Kafka consumer client config, keyed by Topic enum value (region-stable, +# unlike cluster names). Merged onto the consumer config after the cluster config and +# before any explicit override_params, so explicit params still win. +KAFKA_TOPIC_CONSUMER_CONFIG: dict[str, dict[str, Any]] = {} + + # Mapping of default Kafka topic name to cluster name # as per KAFKA_CLUSTERS. # This must be the default name that matches the topic diff --git a/src/sentry/consumers/__init__.py b/src/sentry/consumers/__init__.py index ef1a4d800faa27..123568ff1a79ba 100644 --- a/src/sentry/consumers/__init__.py +++ b/src/sentry/consumers/__init__.py @@ -582,12 +582,13 @@ def get_stream_processor( **extra_kwargs, ) - def build_consumer_config(group_id: str): + def build_consumer_config(group_id: str, topic: Topic | None = consumer_topic): assert cluster is not None consumer_config = build_kafka_consumer_configuration( kafka_config.get_kafka_consumer_cluster_options( cluster, + topic=topic, ), group_id=group_id, auto_offset_reset=auto_offset_reset, @@ -628,8 +629,15 @@ def build_consumer_config(group_id: str): assert synchronize_commit_group is not None assert synchronize_commit_log_topic is not None + # The commit log consumer reads its own topic, so key its per-topic config by that + # topic rather than the main consumer's + try: + commit_log_topic = Topic(synchronize_commit_log_topic) + except ValueError: + commit_log_topic = None + commit_log_consumer = KafkaConsumer( - build_consumer_config(f"sentry-commit-log-{uuid.uuid1().hex}") + build_consumer_config(f"sentry-commit-log-{uuid.uuid1().hex}", topic=commit_log_topic) ) from sentry.consumers.synchronized import SynchronizedConsumer diff --git a/src/sentry/utils/kafka_config.py b/src/sentry/utils/kafka_config.py index 665c086058f054..80f9dd2db5d8fe 100644 --- a/src/sentry/utils/kafka_config.py +++ b/src/sentry/utils/kafka_config.py @@ -86,10 +86,16 @@ def get_kafka_producer_cluster_options(cluster_name: str) -> dict[str, Any]: def get_kafka_consumer_cluster_options( - cluster_name: str, override_params: MutableMapping[str, Any] | None = None + cluster_name: str, + override_params: MutableMapping[str, Any] | None = None, + topic: Topic | None = None, ) -> dict[str, Any]: + # Per-topic consumer config (keyed by the region-stable Topic enum value) layers on + # top of the cluster's consumer config but below any explicit override_params. + topic_config = settings.KAFKA_TOPIC_CONSUMER_CONFIG.get(topic.value, {}) if topic else {} + merged = {**topic_config, **(override_params or {})} return _get_kafka_cluster_options( - cluster_name, CONSUMERS_SECTION, only_bootstrap=True, override_params=override_params + cluster_name, CONSUMERS_SECTION, only_bootstrap=True, override_params=merged or None ) diff --git a/tests/sentry/consumers/test_run.py b/tests/sentry/consumers/test_run.py index 7f5eb837136a75..3954cebbb4631d 100644 --- a/tests/sentry/consumers/test_run.py +++ b/tests/sentry/consumers/test_run.py @@ -4,7 +4,8 @@ from arroyo.processing.strategies.abstract import ProcessingStrategyFactory from sentry import consumers -from sentry.conf.types.kafka_definition import ConsumerDefinition +from sentry.conf.types.kafka_definition import ConsumerDefinition, Topic +from sentry.consumers import get_stream_processor from sentry.utils.imports import import_string @@ -62,6 +63,36 @@ def test_dlq(consumer_def) -> None: assert defn.get("dlq_topic") is not None, f"{consumer_name} consumer is missing DLQ" +def test_commit_log_consumer_config_keyed_by_own_topic() -> None: + topics_seen: list[Topic | None] = [] + + def fake_cluster_options(cluster_name, override_params=None, topic=None): + topics_seen.append(topic) + return {"bootstrap.servers": "127.0.0.1:9092"} + + with ( + patch("sentry.consumers.KafkaConsumer"), + patch("sentry.consumers.StreamProcessor"), + patch("sentry.consumers.synchronized.SynchronizedConsumer"), + patch( + "sentry.utils.kafka_config.get_kafka_consumer_cluster_options", + side_effect=fake_cluster_options, + ), + ): + get_stream_processor( + consumer_name="post-process-forwarder-transactions", + consumer_args=["--mode=multithreaded"], + topic=None, + cluster=None, + group_id="test-group", + auto_offset_reset="earliest", + strict_offset_reset=False, + enable_dlq=False, + ) + + assert topics_seen == [Topic.TRANSACTIONS, Topic.TRANSACTIONS_COMMIT_LOG] + + def test_apply_processor_args_overrides() -> None: """Test the apply_processor_args_overrides function.""" from sentry.consumers import apply_processor_args_overrides diff --git a/tests/sentry/utils/test_kafka_config.py b/tests/sentry/utils/test_kafka_config.py index 343bb48b7a91db..dd616ab5d36c61 100644 --- a/tests/sentry/utils/test_kafka_config.py +++ b/tests/sentry/utils/test_kafka_config.py @@ -4,6 +4,7 @@ from django.conf import settings from django.test import override_settings +from sentry.conf.types.kafka_definition import Topic from sentry.utils.kafka_config import ( get_kafka_admin_cluster_options, get_kafka_consumer_cluster_options, @@ -114,3 +115,66 @@ def test_legacy_custom_mix_customer() -> None: cluster_options = get_kafka_consumer_cluster_options("default") assert cluster_options["bootstrap.servers"] == "old.server:9092" assert "security.protocol" not in cluster_options + + +def test_consumer_options_topic_config_applied() -> None: + with override_settings( + KAFKA_TOPIC_CONSUMER_CONFIG={ + Topic.INGEST_TRANSACTIONS.value: {"max.poll.interval.ms": 60000} + } + ): + cluster_options = get_kafka_consumer_cluster_options( + "default", topic=Topic.INGEST_TRANSACTIONS + ) + assert cluster_options["max.poll.interval.ms"] == 60000 + + +def test_consumer_options_topic_config_absent_for_other_topics() -> None: + with override_settings( + KAFKA_TOPIC_CONSUMER_CONFIG={ + Topic.INGEST_TRANSACTIONS.value: {"max.poll.interval.ms": 60000} + } + ): + # A topic not listed in the config gets nothing extra. + cluster_options = get_kafka_consumer_cluster_options("default", topic=Topic.INGEST_EVENTS) + assert "max.poll.interval.ms" not in cluster_options + + # No topic passed (e.g. the ops transfer script) also gets nothing. + cluster_options = get_kafka_consumer_cluster_options("default") + assert "max.poll.interval.ms" not in cluster_options + + +def test_consumer_options_explicit_override_params_win_over_topic_config() -> None: + with override_settings( + KAFKA_TOPIC_CONSUMER_CONFIG={ + Topic.INGEST_TRANSACTIONS.value: {"max.poll.interval.ms": 60000} + } + ): + cluster_options = get_kafka_consumer_cluster_options( + "default", + override_params={"max.poll.interval.ms": 120000}, + topic=Topic.INGEST_TRANSACTIONS, + ) + assert cluster_options["max.poll.interval.ms"] == 120000 + + +def test_consumer_options_topic_config_supplies_value_without_cluster_entry() -> None: + # Parity with the STREAM-1058 end state: the cluster carries no consumer config, yet the + # transactions consumers still get max.poll.interval.ms from the topic-keyed path. + with override_settings( + KAFKA_CLUSTERS={ + "default": {"common": {"bootstrap.servers": "127.0.0.1:9092"}, "consumers": {}} + }, + KAFKA_TOPIC_CONSUMER_CONFIG={ + Topic.TRANSACTIONS.value: {"max.poll.interval.ms": 60000}, + Topic.INGEST_TRANSACTIONS.value: {"max.poll.interval.ms": 60000}, + Topic.TRANSACTIONS_SUBSCRIPTIONS_RESULTS.value: {"max.poll.interval.ms": 60000}, + }, + ): + for topic in ( + Topic.TRANSACTIONS, + Topic.INGEST_TRANSACTIONS, + Topic.TRANSACTIONS_SUBSCRIPTIONS_RESULTS, + ): + cluster_options = get_kafka_consumer_cluster_options("default", topic=topic) + assert cluster_options["max.poll.interval.ms"] == 60000 From 6c417a7e0e0af5b845ec6ff7d55a2af3e5d9b7c2 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 2 Jun 2026 10:04:11 -0700 Subject: [PATCH 08/46] ref(flags): Remove organizations:seer-slack-workflows (#116640) Dead flag. Remove the flag registration (sole reference in the codebase). From fc2e06e05ddcf10be1c7bba5e1f4e27fbfcaaf1b Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Tue, 2 Jun 2026 13:10:31 -0400 Subject: [PATCH 09/46] chore(api): mark prompts-activity as private (#116702) Mark the `prompts-activity` endpoint as private, since it's only used for sentry analytics(?) --- src/sentry/api/endpoints/prompts_activity.py | 4 ++-- .../apidocs/api_publish_status_allowlist_dont_modify.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/endpoints/prompts_activity.py b/src/sentry/api/endpoints/prompts_activity.py index 057c3c6af0476a..9f0d03937780e8 100644 --- a/src/sentry/api/endpoints/prompts_activity.py +++ b/src/sentry/api/endpoints/prompts_activity.py @@ -45,8 +45,8 @@ class PromptsActivityPermission(OrganizationPermission): @cell_silo_endpoint class PromptsActivityEndpoint(OrganizationEndpoint): publish_status = { - "GET": ApiPublishStatus.UNKNOWN, - "PUT": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, + "PUT": ApiPublishStatus.PRIVATE, } permission_classes = (PromptsActivityPermission,) diff --git a/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py b/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py index cec095e86eb74e..707c898d165b75 100644 --- a/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py +++ b/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py @@ -78,5 +78,4 @@ "/api/0/sentry-app-installations/{uuid}/": {"DELETE", "GET", "PUT"}, "/api/0/sentry-app-installations/{uuid}/external-issues/": {"POST"}, "/api/0/sentry-app-installations/{uuid}/external-issues/{external_issue_id}/": {"DELETE"}, - "/api/0/organizations/{organization_id_or_slug}/prompts-activity/": {"GET", "PUT"}, } From 4252bffec4ec88a729f1ae6142e93c862ca33b85 Mon Sep 17 00:00:00 2001 From: joshuarli Date: Tue, 2 Jun 2026 10:21:35 -0700 Subject: [PATCH 10/46] ci: devservices 1.4.0 (#116700) --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 850f2518d0d2d5..81cf099c291d12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,7 +153,7 @@ default = true dev = [ "codeowners-coverage>=0.3.0", "covdefaults>=2.3.0", - "devservices>=1.3.2", + "devservices>=1.4.0", "docker>=7.1.0", "ephemeral-port-reserve>=1.1.4", "flake8>=7.3.0", diff --git a/uv.lock b/uv.lock index fd747f9936329c..9fde919c2ae62a 100644 --- a/uv.lock +++ b/uv.lock @@ -307,7 +307,7 @@ wheels = [ [[package]] name = "devservices" -version = "1.3.2" +version = "1.4.0" source = { registry = "https://pypi.devinfra.sentry.io/simple" } dependencies = [ { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -317,7 +317,7 @@ dependencies = [ { name = "supervisor", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] wheels = [ - { url = "https://pypi.devinfra.sentry.io/wheels/devservices-1.3.2-py3-none-any.whl", hash = "sha256:70b92e0baea17a52895198259121419d687dcab1e520b0509de7c2ed90c6058c" }, + { url = "https://pypi.devinfra.sentry.io/wheels/devservices-1.4.0-py3-none-any.whl", hash = "sha256:259b2cbf8f129474cddcef2d952e1b23a22d274ea50cca816db4c97789bfbf8c" }, ] [[package]] @@ -2457,7 +2457,7 @@ requires-dist = [ dev = [ { name = "codeowners-coverage", specifier = ">=0.3.0" }, { name = "covdefaults", specifier = ">=2.3.0" }, - { name = "devservices", specifier = ">=1.3.2" }, + { name = "devservices", specifier = ">=1.4.0" }, { name = "django-stubs", specifier = ">=5.2.9" }, { name = "djangorestframework-stubs", specifier = ">=3.16.8" }, { name = "docker", specifier = ">=7.1.0" }, From 81c1becba2b3c3c65c271d71377bfe97e8059849 Mon Sep 17 00:00:00 2001 From: William Mak Date: Tue, 2 Jun 2026 13:26:49 -0400 Subject: [PATCH 11/46] fix(events): Don't default the seer referrers (#116704) - Currently we default bearer token requests to AUTH_TOKEN so api users don't spam us with incorrect refererrs, but we don't want to do this for seer so adding a special case for them --- src/sentry/api/endpoints/organization_events.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/sentry/api/endpoints/organization_events.py b/src/sentry/api/endpoints/organization_events.py index 2f54d8a73228c4..0ac54fbe3337ec 100644 --- a/src/sentry/api/endpoints/organization_events.py +++ b/src/sentry/api/endpoints/organization_events.py @@ -229,7 +229,14 @@ def get(self, request: Request, organization: Organization) -> Response: # Force the referrer to "api.auth-token.events" for events requests authorized through a bearer token if request.auth: - referrer = Referrer.API_AUTH_TOKEN_EVENTS.value + if ( + referrer is not None + and is_valid_referrer(referrer) + and referrer.startswith("seer.") + ): + sentry_sdk.set_tag("query.from_seer", True) + else: + referrer = Referrer.API_AUTH_TOKEN_EVENTS.value elif referrer is None or not referrer: referrer = Referrer.API_ORGANIZATION_EVENTS.value elif not is_valid_referrer(referrer): From e70b763dd4e7904dedc71e438a39d34f69022c60 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 2 Jun 2026 12:43:41 -0500 Subject: [PATCH 12/46] feat(scraps): RevealOnHover compound component (#115953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS-only compound component for the "reveal secondary actions on hover" pattern. Replaces 35+ ad-hoc implementations across the codebase that each roll their own `opacity`/`visibility`/`onMouseEnter` approach — ~60% of which have accessibility gaps (no `:focus-within`, no touch device support). --- Co-authored-by: Priscila Oliveira <29228205+priscilawebdev@users.noreply.github.com> --- knip.config.ts | 1 + .../components/core/revealOnHover/index.tsx | 1 + .../core/revealOnHover/revealOnHover.mdx | 238 ++++++++++++++++++ .../core/revealOnHover/revealOnHover.spec.tsx | 150 +++++++++++ .../core/revealOnHover/revealOnHover.tsx | 77 ++++++ 5 files changed, 467 insertions(+) create mode 100644 static/app/components/core/revealOnHover/index.tsx create mode 100644 static/app/components/core/revealOnHover/revealOnHover.mdx create mode 100644 static/app/components/core/revealOnHover/revealOnHover.spec.tsx create mode 100644 static/app/components/core/revealOnHover/revealOnHover.tsx diff --git a/knip.config.ts b/knip.config.ts index 494668c439ff2d..bb06cd987ec32f 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -17,6 +17,7 @@ const productionEntryPoints = [ // Only used in stories (so far) 'static/app/components/core/quote/*.tsx', 'static/app/components/core/markdown/**/*.{ts,tsx}', + 'static/app/components/core/revealOnHover/*.tsx', // todo we currently keep all icons 'static/app/icons/**/*.{js,ts,tsx}', // todo find out how chartcuterie works diff --git a/static/app/components/core/revealOnHover/index.tsx b/static/app/components/core/revealOnHover/index.tsx new file mode 100644 index 00000000000000..414f53f330b1a5 --- /dev/null +++ b/static/app/components/core/revealOnHover/index.tsx @@ -0,0 +1 @@ +export {RevealOnHover} from './revealOnHover'; diff --git a/static/app/components/core/revealOnHover/revealOnHover.mdx b/static/app/components/core/revealOnHover/revealOnHover.mdx new file mode 100644 index 00000000000000..f4ec5370b3a5a0 --- /dev/null +++ b/static/app/components/core/revealOnHover/revealOnHover.mdx @@ -0,0 +1,238 @@ +--- +title: RevealOnHover +description: A container that reveals secondary actions (like copy or delete buttons) on hover and focus, with built-in accessibility and touch device support. +category: interaction +source: '@sentry/scraps/revealOnHover' +resources: + js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/revealOnHover/revealOnHover.tsx + a11y: + WCAG 1.4.13: https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus + WCAG 3.2.7: https://www.w3.org/TR/WCAG22/#visible-controls +--- + +import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {Tooltip} from '@sentry/scraps/tooltip'; +import {RevealOnHover} from '@sentry/scraps/revealOnHover'; + +import {IconCopy, IconDelete, IconEdit} from 'sentry/icons'; + +import * as Storybook from 'sentry/stories'; + +export const documentation = + import('!!type-loader!@sentry/scraps/revealOnHover/revealOnHover'); + +Use `` to hide secondary actions at rest and reveal them when the user hovers or focuses within the container. Actions are always visible on touch devices. + + + + Hover to reveal the action + + + + + + ); + + expect(screen.getByText('Label')).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Copy'})).toBeInTheDocument(); + }); + + it('wraps action children with a data-reveal-on-hover element', () => { + render( + + Label + + + + + ); + + const button = screen.getByRole('button', {name: 'Copy'}); + expect(button.closest('[data-reveal-on-hover]')).toBeInTheDocument(); + }); + + it('does not wrap with data-reveal-on-hover when visible is true', () => { + render( + + Label + + + + + ); + + const button = screen.getByRole('button', {name: 'Copy'}); + expect(button.closest('[data-reveal-on-hover]')).not.toBeInTheDocument(); + }); + + it('passes through Flex props to the root element', () => { + render( + + Label + + + + + ); + + expect(screen.getByTestId('hover-root')).toBeInTheDocument(); + }); + + it('action button is clickable', async () => { + const onClick = jest.fn(); + + render( + + Label + + + + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Copy'})); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('supports multiple actions', () => { + render( + + Label + + + + + + + + ); + + const copyButton = screen.getByRole('button', {name: 'Copy'}); + const deleteButton = screen.getByRole('button', {name: 'Delete'}); + expect(copyButton.closest('[data-reveal-on-hover]')).toBeInTheDocument(); + expect(deleteButton.closest('[data-reveal-on-hover]')).toBeInTheDocument(); + }); + + it('action button is focusable via keyboard', async () => { + render( + + Label + + + + + ); + + await userEvent.tab(); + expect(screen.getByRole('button', {name: 'Copy'})).toHaveFocus(); + }); + + it('supports callback children for custom elements', () => { + render( + + {({className}) => ( +
+ Grid content + + + +
+ )} +
+ ); + + expect(screen.getByTestId('custom-root')).toBeInTheDocument(); + expect(screen.getByText('Grid content')).toBeInTheDocument(); + const button = screen.getByRole('button', {name: 'Copy'}); + expect(button.closest('[data-reveal-on-hover]')).toBeInTheDocument(); + }); + + it('callback children action button is clickable', async () => { + const onClick = jest.fn(); + + render( + + {({className}) => ( +
+ Content + + + +
+ )} +
+ ); + + await userEvent.click(screen.getByRole('button', {name: 'Copy'})); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/static/app/components/core/revealOnHover/revealOnHover.tsx b/static/app/components/core/revealOnHover/revealOnHover.tsx new file mode 100644 index 00000000000000..05ff9871f1f80d --- /dev/null +++ b/static/app/components/core/revealOnHover/revealOnHover.tsx @@ -0,0 +1,77 @@ +import styled from '@emotion/styled'; + +import {Flex, type FlexProps} from '@sentry/scraps/layout'; +import type {ContainerElement} from '@sentry/scraps/layout/container'; + +interface RevealOnHoverRenderProps { + className: string; +} + +type RevealOnHoverProps = + | (FlexProps & {children: React.ReactNode}) + | {children: (props: RevealOnHoverRenderProps) => React.ReactNode}; + +function RevealOnHoverRoot(props: RevealOnHoverProps) { + const {children, ...rest} = props; + + if (typeof children === 'function') { + return ( + {({className}) => children({className})} + ); + } + + return ( + + {children} + + ); +} + +const revealStyles = (p: {theme: import('@emotion/react').Theme}) => ` + @media (hover: hover) { + [data-reveal-on-hover] { + opacity: 0; + pointer-events: none; + transition: opacity ${p.theme.motion.exit.fast}; + } + + &:hover [data-reveal-on-hover], + &:focus-within [data-reveal-on-hover] { + opacity: 1; + pointer-events: auto; + transition: opacity ${p.theme.motion.enter.moderate}; + } + } +`; + +function RevealOnHoverFlex(props: FlexProps) { + return revealStyles({theme})} {...props} />; +} + +const RevealOnHoverStyles = styled( + (props: { + children: (renderProps: RevealOnHoverRenderProps) => React.ReactNode; + className?: string; + }) => { + return props.children({className: props.className ?? ''}); + } +)` + ${revealStyles} +`; + +interface ActionProps { + children: React.ReactNode; + visible?: boolean; +} + +function Action({children, visible}: ActionProps) { + if (visible) { + return children; + } + + return {children}; +} + +export const RevealOnHover = Object.assign(RevealOnHoverRoot, { + Action, +}); From 52f80d7f918d0a073b95289d0382827f28c4ca0b Mon Sep 17 00:00:00 2001 From: gricha <875316+gricha@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:49:17 +0000 Subject: [PATCH 13/46] release: 26.5.2 --- CHANGES | 489 ++++++++++++++++++++++++++++++++++++++ setup.cfg | 2 +- src/sentry/conf/server.py | 2 +- 3 files changed, 491 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 94c6903d9410eb..b825bdf809a076 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,492 @@ +26.5.2 +------ + +### New Features ✨ + +#### Admin + +- Filter invoice comparison to both-sides orgs + parity metric by @armcknight in [#116420](https://github.com/getsentry/sentry/pull/116420) +- Add Billing Platform admin page with invoice comparison by @armcknight in [#116269](https://github.com/getsentry/sentry/pull/116269) + +#### Api + +- Union response annotations with plugin narrowing + relaxed linter by @azulus in [#116659](https://github.com/getsentry/sentry/pull/116659) +- Add [T] to 33 Serializer subclasses by @azulus in [#116629](https://github.com/getsentry/sentry/pull/116629) +- Add Serializer[T] generic; pilot on environments by @azulus in [#116538](https://github.com/getsentry/sentry/pull/116538) +- Opt 43 endpoints into Response[T] typed bodies by @azulus in [#116433](https://github.com/getsentry/sentry/pull/116433) +- Type @extend_schema responses via Response[T] stub + linter by @azulus in [#116335](https://github.com/getsentry/sentry/pull/116335) + +#### Api Docs + +- Publish source map debug endpoint by @cvxluo in [#116649](https://github.com/getsentry/sentry/pull/116649) +- Publish organization profile chunks endpoint by @cvxluo in [#116632](https://github.com/getsentry/sentry/pull/116632) +- Publish organization trace endpoint by @cvxluo in [#116596](https://github.com/getsentry/sentry/pull/116596) +- Publish project profiling profile endpoint by @cvxluo in [#116597](https://github.com/getsentry/sentry/pull/116597) +- Publish organization profiling flamegraph endpoint by @cvxluo in [#116449](https://github.com/getsentry/sentry/pull/116449) +- Publish group hashes endpoint by @cvxluo in [#116029](https://github.com/getsentry/sentry/pull/116029) +- Publish event attachment details endpoint by @cvxluo in [#116580](https://github.com/getsentry/sentry/pull/116580) +- Publish organization trace meta endpoint by @cvxluo in [#116445](https://github.com/getsentry/sentry/pull/116445) +- Publish event attachments list endpoint by @cvxluo in [#116536](https://github.com/getsentry/sentry/pull/116536) +- Publish project releases list endpoint by @cvxluo in [#116220](https://github.com/getsentry/sentry/pull/116220) +- Publish organization trace item attributes endpoint by @cvxluo in [#116398](https://github.com/getsentry/sentry/pull/116398) +- Publish project debug files list endpoint by @cvxluo in [#116444](https://github.com/getsentry/sentry/pull/116444) +- Publish group details endpoint by @cvxluo in [#116119](https://github.com/getsentry/sentry/pull/116119) + +#### Autofix + +- Allow retry creating PR by @Zylphrex in [#116518](https://github.com/getsentry/sentry/pull/116518) +- Link linear ticket in autofix PR by @Zylphrex in [#116510](https://github.com/getsentry/sentry/pull/116510) +- Add Seer Agent debug button to Autofix header by @sentry-junior in [#116166](https://github.com/getsentry/sentry/pull/116166) + +#### Bitbucket Server + +- Route install through API pipeline modal by @evanpurkhiser in [#116314](https://github.com/getsentry/sentry/pull/116314) +- Add frontend pipeline steps for Bitbucket Server integration setup by @evanpurkhiser in [#116294](https://github.com/getsentry/sentry/pull/116294) +- Add API-driven pipeline backend for Bitbucket Server integration setup by @evanpurkhiser in [#116295](https://github.com/getsentry/sentry/pull/116295) + +#### Cells + +- Use control silo organization listing for setup wizard by @lynnagara in [#116423](https://github.com/getsentry/sentry/pull/116423) +- Implement owner=1 on control silo org listing by @lynnagara in [#116439](https://github.com/getsentry/sentry/pull/116439) +- Remove cross-org feature gating from quota notifications by @lynnagara in [#115937](https://github.com/getsentry/sentry/pull/115937) + +#### Conversations + +- Add conversation ID to freeform search suggestions by @obostjancic in [#116568](https://github.com/getsentry/sentry/pull/116568) +- Update default search hints for AI conversations by @obostjancic in [#116561](https://github.com/getsentry/sentry/pull/116561) +- Improve freeform search to target conversation fields by @obostjancic in [#116562](https://github.com/getsentry/sentry/pull/116562) +- Expand JSON with higher auto-collapse limit in messages panel by @obostjancic in [#116368](https://github.com/getsentry/sentry/pull/116368) + +#### Dynamic Sampling + +- Per-org transaction rebalancing by @constantinius in [#116475](https://github.com/getsentry/sentry/pull/116475) +- Add project rebalancing to per-org pipeline by @shellmayr in [#116393](https://github.com/getsentry/sentry/pull/116393) +- Add sliding window calculation to per-org by @shellmayr in [#116083](https://github.com/getsentry/sentry/pull/116083) +- Add per-org EAP transaction volume query by @constantinius in [#115161](https://github.com/getsentry/sentry/pull/115161) + +#### Eslint + +- Add css interpolation semi rule by @scttcper in [#116428](https://github.com/getsentry/sentry/pull/116428) +- Add no-raw-css-in-styled rule by @scttcper in [#115934](https://github.com/getsentry/sentry/pull/115934) +- Add prefer-info-text lint rule and migrate existing usages by @TkDodo in [#116211](https://github.com/getsentry/sentry/pull/116211) + +#### Explore + +- Promote schema hints removal from now-done logs to remaining pages by @JoshuaKGoldberg in [#116224](https://github.com/getsentry/sentry/pull/116224) +- Switch feature flag from ourlogs- to explore-schema-hints-removal by @JoshuaKGoldberg in [#116225](https://github.com/getsentry/sentry/pull/116225) +- Space out heat maps y-axis labels by @nikkikapadia in [#116341](https://github.com/getsentry/sentry/pull/116341) + +#### Issues + +- Add issue.agent search filter by @malwilley in [#116584](https://github.com/getsentry/sentry/pull/116584) +- Add pretty rendering for Android Runtime (ART) event context by @markushi in [#116270](https://github.com/getsentry/sentry/pull/116270) +- Extend event context formatters for mobile SDKs by @markushi in [#116273](https://github.com/getsentry/sentry/pull/116273) +- Restore issue details tour, remove guide by @scttcper in [#116355](https://github.com/getsentry/sentry/pull/116355) +- Refine low-value span configuration UI by @ArthurKnaus in [#116460](https://github.com/getsentry/sentry/pull/116460) +- Fully enable recording of Seer actions as issue activities (with option) by @shashjar in [#116424](https://github.com/getsentry/sentry/pull/116424) +- Use shared markdown component for activity notes by @scttcper in [#116300](https://github.com/getsentry/sentry/pull/116300) +- Consolidate user feedback activity styles by @scttcper in [#116318](https://github.com/getsentry/sentry/pull/116318) + +#### Jira + +- Wire Marketplace installs through the API pipeline modal by @evanpurkhiser in [#116525](https://github.com/getsentry/sentry/pull/116525) +- Support installing through the API pipeline modal by @evanpurkhiser in [#116500](https://github.com/getsentry/sentry/pull/116500) + +#### Msteams + +- Wire Teams Marketplace installs through the API pipeline modal by @evanpurkhiser in [#116488](https://github.com/getsentry/sentry/pull/116488) +- Support installing through the API pipeline modal by @evanpurkhiser in [#116490](https://github.com/getsentry/sentry/pull/116490) + +#### Ourlogs + +- Add `truncate` RPC parameter for logs events query by @JoshuaKGoldberg in [#116008](https://github.com/getsentry/sentry/pull/116008) +- Add tab click tracking for Logs and Traces explore tabs by @JoshuaKGoldberg in [#115748](https://github.com/getsentry/sentry/pull/115748) +- Use `truncate` parameter in page-level logs requests by @JoshuaKGoldberg in [#116009](https://github.com/getsentry/sentry/pull/116009) + +#### Preprod + +- Fix snapshot tag filtering and make tags interactive by @mtopo27 in [#116330](https://github.com/getsentry/sentry/pull/116330) +- Add structured tags to snapshot test metadata by @mtopo27 in [#116307](https://github.com/getsentry/sentry/pull/116307) + +#### Repositories + +- Backfill auto-link repos by name matching by @wedamija in [#116541](https://github.com/getsentry/sentry/pull/116541) +- Auto-link repos to projects by name matching by @wedamija in [#116533](https://github.com/getsentry/sentry/pull/116533) + +#### Seer + +- Gate structured context routes on rollout flag by @Mihir-Mavalankar in [#116605](https://github.com/getsentry/sentry/pull/116605) +- Add flag to roll out structured page context to all orgs by @Mihir-Mavalankar in [#116600](https://github.com/getsentry/sentry/pull/116600) +- Remove stale feature flag `organizations:seer-agent-pr-consolidation` by @cvxluo in [#116438](https://github.com/getsentry/sentry/pull/116438) +- Add structured LLM context for metrics and profiling explorer page by @Mihir-Mavalankar in [#116250](https://github.com/getsentry/sentry/pull/116250) + +#### Workflow Engine + +- Update delayed processing and add evaluation logs by @saponifi3d in [#115692](https://github.com/getsentry/sentry/pull/115692) +- Implement Seer Activity handler by @leeandher in [#116410](https://github.com/getsentry/sentry/pull/116410) + +#### Other + +- (activity) Add (project, type) index on sentry_activity by @malwilley in [#116524](https://github.com/getsentry/sentry/pull/116524) +- (apidocs) Support union Response[T] annotations in structural linter by @azulus in [#116496](https://github.com/getsentry/sentry/pull/116496) +- (cmdk) Add search keywords to reduce no-result queries by @JonasBa in [#116431](https://github.com/getsentry/sentry/pull/116431) +- (dashboards) Track dashboard generation validation attempts by @DominikB2014 in [#116502](https://github.com/getsentry/sentry/pull/116502) +- (discord) Wire App Directory installs through the API pipeline modal by @evanpurkhiser in [#116429](https://github.com/getsentry/sentry/pull/116429) +- (eap) Add superuser `debug` param to trace item attributes by @mjq in [#116579](https://github.com/getsentry/sentry/pull/116579) +- (flagpole) Register onboarding-scm-project-creation-experiment feature flag by @jaydgoss in [#116189](https://github.com/getsentry/sentry/pull/116189) +- (github-enterprise) Route install through API pipeline modal by @evanpurkhiser in [#116316](https://github.com/getsentry/sentry/pull/116316) +- (ingest) Allow custom global inbound filter by @oioki in [#116685](https://github.com/getsentry/sentry/pull/116685) +- (issue-details) Enable Autofix for low-value spans by @ArthurKnaus in [#116468](https://github.com/getsentry/sentry/pull/116468) +- (loader) Add pride loader by @natemoo-re in [#116348](https://github.com/getsentry/sentry/pull/116348) +- (markdown) Add tag extension by @natemoo-re in [#116504](https://github.com/getsentry/sentry/pull/116504) +- (night-shift) Be more conservative about which issues get autofixed by @chromy in [#116476](https://github.com/getsentry/sentry/pull/116476) +- (onboarding) Add ScmAnalyticsFlow for project-creation reuse by @jaydgoss in [#116434](https://github.com/getsentry/sentry/pull/116434) +- (replays) Add superuser replay debugger dropdown option by @billyvg in [#116391](https://github.com/getsentry/sentry/pull/116391) +- (scm) Add github_enterprise support to SCM Platform RPC dispatch by @tnt-sentry in [#116193](https://github.com/getsentry/sentry/pull/116193) +- (scraps) RevealOnHover compound component by @natemoo-re in [#115953](https://github.com/getsentry/sentry/pull/115953) +- (seer explorer) Add unread message count to the tab icon by @sehr-m in [#114071](https://github.com/getsentry/sentry/pull/114071) +- (seer-activity) Set up the new Seer Activity data condition by @leeandher in [#116506](https://github.com/getsentry/sentry/pull/116506) +- (settings) Support legacy usage-based Seer in project settings endpoint by @srest2021 in [#115962](https://github.com/getsentry/sentry/pull/115962) +- (skills) Add analytics instrumentation skill by @natemoo-re in [#116437](https://github.com/getsentry/sentry/pull/116437) +- (snapshots) Add viewport width support to snapshot testing framework by @mtopo27 in [#115887](https://github.com/getsentry/sentry/pull/115887) +- (trace) Add `debug` param to trace item details endpoint by @mjq in [#116151](https://github.com/getsentry/sentry/pull/116151) +- (trace-waterfall) Add "EAP JSON" debug button for superusers by @mjq in [#116131](https://github.com/getsentry/sentry/pull/116131) +- (utils) Add shuffle option to CursoredScheduler by @roggenkemper in [#116297](https://github.com/getsentry/sentry/pull/116297) +- (waterfall) Add visual indication for SDK-sent v2 spans by @Lms24 in [#116386](https://github.com/getsentry/sentry/pull/116386) +- (webhooks) Add REST API endpoint for webhook URL management by @Christinarlong in [#115861](https://github.com/getsentry/sentry/pull/115861) +- Add KAFKA_TOPIC_CONSUMER_CONFIG for per-topic consumer config by @enochtangg in [#116611](https://github.com/getsentry/sentry/pull/116611) +- Reorder get topic to resolve override before lookup by @enochtangg in [#116337](https://github.com/getsentry/sentry/pull/116337) +- Remove code coverage feature by @calvin-codecov in [#116240](https://github.com/getsentry/sentry/pull/116240) +- Install sentry-options by @joshuarli in [#115835](https://github.com/getsentry/sentry/pull/115835) + +### Bug Fixes 🐛 + +#### Aci + +- Remove openIssues from Detector serializer response by @ceorourke in [#116414](https://github.com/getsentry/sentry/pull/116414) +- Scope rule workflow lookups by organization by @kcons in [#116353](https://github.com/getsentry/sentry/pull/116353) + +#### Api Logs + +- Log snuba throttle_threshold on rate-limited requests by @cvxluo in [#116338](https://github.com/getsentry/sentry/pull/116338) +- Preserve snuba policy info on throttles by @cvxluo in [#116263](https://github.com/getsentry/sentry/pull/116263) + +#### Eap + +- Handle None exception data in event forwarding by @roggenkemper in [#116544](https://github.com/getsentry/sentry/pull/116544) +- Recognize `normalize` deprecations in attribute mapping by @mjq in [#116509](https://github.com/getsentry/sentry/pull/116509) + +#### Feedback + +- Remove extra padding from LayoutGrid component by @sentry-junior in [#116377](https://github.com/getsentry/sentry/pull/116377) +- Make UserReport name and email nullable by @TkDodo in [#116362](https://github.com/getsentry/sentry/pull/116362) + +#### Integrations + +- Hide the integration Settings tab when it is empty by @evanpurkhiser in [#116688](https://github.com/getsentry/sentry/pull/116688) +- Return the proper error response shape from the integration details POST endpoint by @malwilley in [#116447](https://github.com/getsentry/sentry/pull/116447) +- Use paginated jira projects endpoint in another place by @hobzcalvin in [#116418](https://github.com/getsentry/sentry/pull/116418) +- Use paginated jira projects endpoint, behind flag by @hobzcalvin in [#116327](https://github.com/getsentry/sentry/pull/116327) + +#### Issues + +- Make linked issue metadata clickable by @scttcper in [#116583](https://github.com/getsentry/sentry/pull/116583) +- Read low-value span evidence as camelCase by @ArthurKnaus in [#116557](https://github.com/getsentry/sentry/pull/116557) + +#### Logs + +- Go back to prefetch query by @k-fish in [#114893](https://github.com/getsentry/sentry/pull/114893) +- Pass timestamp to trace item details by @nsdeschenes in [#116374](https://github.com/getsentry/sentry/pull/116374) + +#### Metrics + +- Pass timestamp to trace item details by @nsdeschenes in [#116315](https://github.com/getsentry/sentry/pull/116315) +- Skip tag validation when deleting Snuba subscriptions by @wedamija in [#116325](https://github.com/getsentry/sentry/pull/116325) + +#### Preprod + +- Document latest base project slug filter by @cameroncooke in [#116102](https://github.com/getsentry/sentry/pull/116102) +- Balance padding on active tag filter chips by @NicoHinderling in [#116417](https://github.com/getsentry/sentry/pull/116417) +- Enforce project access for artifact endpoints by @cameroncooke in [#116381](https://github.com/getsentry/sentry/pull/116381) +- Pre-filter latest base snapshot query by project access by @NicoHinderling in [#116319](https://github.com/getsentry/sentry/pull/116319) + +#### Replays + +- Query canonical replay id in trace tab by @romtsn in [#116432](https://github.com/getsentry/sentry/pull/116432) +- Scope issue.id group lookup to caller's accessible projects by @JoshuaKGoldberg in [#116188](https://github.com/getsentry/sentry/pull/116188) +- Stop page reloads on initial tab change by @nsdeschenes in [#116494](https://github.com/getsentry/sentry/pull/116494) + +#### Workflows + +- Rule deletion shouldn't automatically result in Workflow deletion by @kcons in [#116537](https://github.com/getsentry/sentry/pull/116537) +- Update Workflows with org-scoped envs when transfered with a project by @kcons in [#116239](https://github.com/getsentry/sentry/pull/116239) + +#### Other + +- (alerts) Fall through to issue alert handler by @ceorourke in [#116241](https://github.com/getsentry/sentry/pull/116241) +- (api) Rename duplicated event reprocessable URL by @cvxluo in [#116395](https://github.com/getsentry/sentry/pull/116395) +- (api-docs) Improve flamegraph endpoint description by @cvxluo in [#116633](https://github.com/getsentry/sentry/pull/116633) +- (autofix) Set default stopping point based on preferences by @Zylphrex in [#116340](https://github.com/getsentry/sentry/pull/116340) +- (ci) Revert parallel devservices startup for backend tests by @mchen-sentry in [#116648](https://github.com/getsentry/sentry/pull/116648) +- (conversations) Use 24h statsPeriod on detail page back link by @obostjancic in [#116361](https://github.com/getsentry/sentry/pull/116361) +- (dashboards) Move global filter loading spinner to dropdown footer by @DominikB2014 in [#116342](https://github.com/getsentry/sentry/pull/116342) +- (data-scrubbing) Stop source field suggestion scroll from crashing by @scttcper in [#116653](https://github.com/getsentry/sentry/pull/116653) +- (discord) Route App Directory install through API pipeline modal by @evanpurkhiser in [#116375](https://github.com/getsentry/sentry/pull/116375) +- (discover) Link issue event ids directly by @scttcper in [#116507](https://github.com/getsentry/sentry/pull/116507) +- (dynamic-sampling) Exclude zero-volume projects from project balancing by @shellmayr in [#116572](https://github.com/getsentry/sentry/pull/116572) +- (events) Don't default the seer referrers by @wmak in [#116704](https://github.com/getsentry/sentry/pull/116704) +- (eventstream) Guard against None entries in exception values list by @roggenkemper in [#116511](https://github.com/getsentry/sentry/pull/116511) +- (explore) Y-axis formatting decimal truncation for heatmaps by @nikkikapadia in [#116144](https://github.com/getsentry/sentry/pull/116144) +- (forms) Surface backend error messages in AutoSaveForm by @malwilley in [#116448](https://github.com/getsentry/sentry/pull/116448) +- (grouping) Fix hostname regex bugs, take 2 by @lobsterkatie in [#116587](https://github.com/getsentry/sentry/pull/116587) +- (heatmaps) Very small y-axis values turning into engineering notation and throwing errors by @nikkikapadia in [#116421](https://github.com/getsentry/sentry/pull/116421) +- (jest) Exclude scripts/ from discovery and module resolution by @armcknight in [#116413](https://github.com/getsentry/sentry/pull/116413) +- (low-value-spans) Use project platform for snippets by @ArthurKnaus in [#116675](https://github.com/getsentry/sentry/pull/116675) +- (oauth) Use hashed token lookup and reject tokens for inactive users by @michelletran-sentry in [#116323](https://github.com/getsentry/sentry/pull/116323) +- (options) Suppress option seen logs in debug mode by @JoshFerge in [#116324](https://github.com/getsentry/sentry/pull/116324) +- (ourlogs) Stabilize ECharts chart position to prevent getAttribute crash by @JoshuaKGoldberg in [#115753](https://github.com/getsentry/sentry/pull/115753) +- (pageFilters) Sort bookmarked projects above non-member projects by @JonasBa in [#116196](https://github.com/getsentry/sentry/pull/116196) +- (project-filter) Increase bottom margin by @cvxluo in [#116328](https://github.com/getsentry/sentry/pull/116328) +- (releases) Combine duplicate Author type by @cvxluo in [#116358](https://github.com/getsentry/sentry/pull/116358) +- (scm) Map unknown referrer to shared by @cmanallen in [#116403](https://github.com/getsentry/sentry/pull/116403) +- (search-query-builder) Add dynamic fetching to has by @nsdeschenes in [#116097](https://github.com/getsentry/sentry/pull/116097) +- (seer) Fix font color and link position in autofix project settings by @ryan953 in [#116602](https://github.com/getsentry/sentry/pull/116602) +- (segment-enrichment) Propagate conventional user attributes by @mjq in [#116492](https://github.com/getsentry/sentry/pull/116492) +- (settings) List all projects in context picker instead of default 1st page by @hobzcalvin in [#116072](https://github.com/getsentry/sentry/pull/116072) +- (snapshots) Increase snapshot test timeout to 30s by @mtopo27 in [#116378](https://github.com/getsentry/sentry/pull/116378) +- (spans) Deprecations shouldn't shadow public field names by @mjq in [#116387](https://github.com/getsentry/sentry/pull/116387) +- (theme) Update config.theme when mutating user theme option by @TkDodo in [#116336](https://github.com/getsentry/sentry/pull/116336) +- (trace-item-details) Allow timestamp by @wmak in [#116321](https://github.com/getsentry/sentry/pull/116321) +- (trace-waterfall) Pass timestamp to trace item details by @nsdeschenes in [#116376](https://github.com/getsentry/sentry/pull/116376) +- (traces) Downgrade Group.DoesNotExist log to info in trace serialization by @wedamija in [#116322](https://github.com/getsentry/sentry/pull/116322) +- (webhooks) Check webhooks:enabled in new webhook path by @Christinarlong in [#116459](https://github.com/getsentry/sentry/pull/116459) +- Trigger ad-hoc explorer index runs by @shruthilayaj in [#116530](https://github.com/getsentry/sentry/pull/116530) + +### Internal Changes 🔧 + +#### Aci + +- Remove usage of workflow engine redirect flag by @ceorourke in [#116609](https://github.com/getsentry/sentry/pull/116609) +- Update alerts:write settings toggle label to include reference to monitors by @malwilley in [#116313](https://github.com/getsentry/sentry/pull/116313) + +#### Api + +- Mark prompts-activity as private by @cvxluo in [#116702](https://github.com/getsentry/sentry/pull/116702) +- Rename `SourceMapDebugBlueThunderEdition` to `SourceMapDebug` by @cvxluo in [#116619](https://github.com/getsentry/sentry/pull/116619) +- Remove unused source-map-debug endpoint by @cvxluo in [#116594](https://github.com/getsentry/sentry/pull/116594) +- Remove experimental/projects backward-compat shim by @betegon in [#116498](https://github.com/getsentry/sentry/pull/116498) +- Remove unused events-trace-light endpoint by @cvxluo in [#116519](https://github.com/getsentry/sentry/pull/116519) +- Remove stale entries from api ownership and publish status by @cvxluo in [#116400](https://github.com/getsentry/sentry/pull/116400) +- Promote org-scoped project creation endpoint to public by @betegon in [#116333](https://github.com/getsentry/sentry/pull/116333) + +#### Api Docs + +- Add `EventAttachmentSerializerResponse` type and example by @cvxluo in [#116515](https://github.com/getsentry/sentry/pull/116515) +- Add DebugFileSerializerResponse type and example fixture by @cvxluo in [#116397](https://github.com/getsentry/sentry/pull/116397) + +#### Ci + +- Skip type coverage comment if there is no change by @shellmayr in [#116672](https://github.com/getsentry/sentry/pull/116672) +- Skip broken trace item detail tests by @kenzoengineer in [#116497](https://github.com/getsentry/sentry/pull/116497) + +#### Codecov + +- Remove auto_enable_codecov daily job by @giovanni-guidini in [#116570](https://github.com/getsentry/sentry/pull/116570) +- Remove GitHub Codecov account-link hooks by @giovanni-guidini in [#116569](https://github.com/getsentry/sentry/pull/116569) +- Remove stacktrace-coverage endpoint and codecovAccess setting by @giovanni-guidini in [#116565](https://github.com/getsentry/sentry/pull/116565) +- Remove Prevent API endpoints and routes by @giovanni-guidini in [#116559](https://github.com/getsentry/sentry/pull/116559) + +#### Deps + +- Bump js-cookie from 3.0.5 to 3.0.7 by @dependabot in [#116057](https://github.com/getsentry/sentry/pull/116057) +- Update sentry-conventions to 0.10.0 by @mjq in [#116517](https://github.com/getsentry/sentry/pull/116517) + +#### Dynamic Sampling + +- Document config types and simplify dir structure by @shellmayr in [#116462](https://github.com/getsentry/sentry/pull/116462) +- Only run sliding window calculations when config is enabled by @shellmayr in [#116371](https://github.com/getsentry/sentry/pull/116371) +- With multiple org volumes, make sure their duration is clear in scheduler by @shellmayr in [#116367](https://github.com/getsentry/sentry/pull/116367) + +#### Explore + +- Remove raw search replacement flag checks by @nsdeschenes in [#116590](https://github.com/getsentry/sentry/pull/116590) +- Port schema hints list to scraps by @priscilawebdev in [#116159](https://github.com/getsentry/sentry/pull/116159) + +#### Flags + +- Remove organizations:processing-error-analytics by @wedamija in [#116643](https://github.com/getsentry/sentry/pull/116643) +- Remove organizations:workflow-engine-redirect-opt-out by @wedamija in [#116641](https://github.com/getsentry/sentry/pull/116641) +- Remove organizations:seer-slack-explorer by @wedamija in [#116639](https://github.com/getsentry/sentry/pull/116639) +- Remove organizations:search-query-builder-raw-search-replacement by @wedamija in [#116638](https://github.com/getsentry/sentry/pull/116638) +- Remove organizations:insights-alerts by @wedamija in [#116223](https://github.com/getsentry/sentry/pull/116223) + +#### Forms + +- Migrate RequestIntegrationModal to TanStack form system by @priscilawebdev in [#115990](https://github.com/getsentry/sentry/pull/115990) +- Migrate CreateTeamForm to TanStack form system by @priscilawebdev in [#115991](https://github.com/getsentry/sentry/pull/115991) + +#### Github Enterprise + +- Remove legacy pipeline setup views by @evanpurkhiser in [#116436](https://github.com/getsentry/sentry/pull/116436) +- Remove fully-GA github.com source flag checks by @tnt-sentry in [#116385](https://github.com/getsentry/sentry/pull/116385) + +#### Integrations + +- Remove `organizations:integrations-github-project-management` by @cvxluo in [#116551](https://github.com/getsentry/sentry/pull/116551) +- Drop the external-install React route by @evanpurkhiser in [#116426](https://github.com/getsentry/sentry/pull/116426) +- Redirect GitHub installs straight to the link page by @evanpurkhiser in [#116412](https://github.com/getsentry/sentry/pull/116412) +- Clean up integrationOrganizationLink by @evanpurkhiser in [#116415](https://github.com/getsentry/sentry/pull/116415) +- Extract GitHub installation callout from org link view by @evanpurkhiser in [#116379](https://github.com/getsentry/sentry/pull/116379) +- Reorganize pipeline components into per-provider folders by @evanpurkhiser in [#116334](https://github.com/getsentry/sentry/pull/116334) + +#### Issues + +- Add fallback event components codeowner by @scttcper in [#116505](https://github.com/getsentry/sentry/pull/116505) +- Rename feature flag to be specific to displaying Seer actions as issue details activities by @shashjar in [#116425](https://github.com/getsentry/sentry/pull/116425) +- Minor cleanup of boolean logic in escalating issue algorithm by @shashjar in [#116453](https://github.com/getsentry/sentry/pull/116453) +- Remove streamline names from issue details by @scttcper in [#116344](https://github.com/getsentry/sentry/pull/116344) + +#### Logs + +- Add superuser only log json debug button by @Dav1dde in [#116482](https://github.com/getsentry/sentry/pull/116482) +- Update trace item timestamp expectations by @nsdeschenes in [#116405](https://github.com/getsentry/sentry/pull/116405) + +#### Onboarding + +- Update project creation URL to /organizations/{org}/projects/ by @betegon in [#116388](https://github.com/getsentry/sentry/pull/116388) +- Decouple SCM step components from OnboardingContext by @jaydgoss in [#115639](https://github.com/getsentry/sentry/pull/115639) + +#### Repositories + +- When making a ProjectRepository link, upgrade the source if we have a stronger signal by @wedamija in [#116543](https://github.com/getsentry/sentry/pull/116543) +- Mark project repo endpoint as public by @wedamija in [#116343](https://github.com/getsentry/sentry/pull/116343) + +#### Seer + +- Mark seer endpoints as private instead of experimental by @gricha in [#116591](https://github.com/getsentry/sentry/pull/116591) +- Remove `organizations:seer-wizard` by @cvxluo in [#116546](https://github.com/getsentry/sentry/pull/116546) +- Remove `organizations:seer-issue-view` by @cvxluo in [#116528](https://github.com/getsentry/sentry/pull/116528) +- Call project settings update helper in callsites that don't need to update the full Seer project preference by @srest2021 in [#116356](https://github.com/getsentry/sentry/pull/116356) +- Add GitLab code-review web hooks by @cmanallen in [#116317](https://github.com/getsentry/sentry/pull/116317) +- Unify Seer project settings update helper and add tuning and auto_create_pr fields by @srest2021 in [#116352](https://github.com/getsentry/sentry/pull/116352) +- Use get_group_list helper in supergroups-by-group endpoint by @giovanni-guidini in [#116474](https://github.com/getsentry/sentry/pull/116474) +- Get stopping point and handoff directly in callsites that don't need the full project preference by @srest2021 in [#116222](https://github.com/getsentry/sentry/pull/116222) + +#### Settings + +- Migrate project security & privacy form to scraps form by @priscilawebdev in [#116463](https://github.com/getsentry/sentry/pull/116463) +- Remove service hooks forms and routes by @TkDodo in [#116296](https://github.com/getsentry/sentry/pull/116296) + +#### Snapshots + +- Snapshot the snapshots toolbar by @mtopo27 in [#116479](https://github.com/getsentry/sentry/pull/116479) +- Resolve real design-system imports under SSR by @mtopo27 in [#116478](https://github.com/getsentry/sentry/pull/116478) +- Make the snapshots toolbar presentational and de-duplicate by @mtopo27 in [#116477](https://github.com/getsentry/sentry/pull/116477) + +#### Snuba + +- Use metrics.timer instead of bespoke timer helper by @mrduncan in [#115279](https://github.com/getsentry/sentry/pull/115279) +- Use metrics.timer for get_snuba_map timing by @mrduncan in [#116357](https://github.com/getsentry/sentry/pull/116357) +- Re-enable boolean double-write tests by @phacops in [#116390](https://github.com/getsentry/sentry/pull/116390) + +#### Spans + +- Improve flush cleanup coverage by @lvthanh03 in [#116694](https://github.com/getsentry/sentry/pull/116694) +- Move flushed segment cleanup into buffer store by @lvthanh03 in [#116495](https://github.com/getsentry/sentry/pull/116495) +- Move queue updates into spans buffer store by @lvthanh03 in [#116435](https://github.com/getsentry/sentry/pull/116435) +- Use full web vitals attribute strings by @mjq in [#116135](https://github.com/getsentry/sentry/pull/116135) +- Introduce spans buffer store abstraction by @lvthanh03 in [#116382](https://github.com/getsentry/sentry/pull/116382) +- Read deprecations from `sentry-conventions` by @mjq in [#116399](https://github.com/getsentry/sentry/pull/116399) +- Add loaded segment data model by @lvthanh03 in [#116346](https://github.com/getsentry/sentry/pull/116346) + +#### Typing + +- Remove 9 zero-error modules from mypy ignore list by @shashjar in [#116430](https://github.com/getsentry/sentry/pull/116430) +- Remove `sentry.services.eventstore.models` from mypy ignore list by @shashjar in [#116229](https://github.com/getsentry/sentry/pull/116229) + +#### Webhooks + +- Hide PLUGIN action type from available actions endpoint by @Christinarlong in [#116458](https://github.com/getsentry/sentry/pull/116458) +- Remove raise that short circuit url sending by @Christinarlong in [#116534](https://github.com/getsentry/sentry/pull/116534) +- Add legacy_webhook to the Plugin ActionType by @Christinarlong in [#116454](https://github.com/getsentry/sentry/pull/116454) + +#### Other + +- (alerts) Disable alert buttons for users without write access by @malwilley in [#116306](https://github.com/getsentry/sentry/pull/116306) +- (apigateway) Use a threadlocal session for proxy requests by @JoshFerge in [#116054](https://github.com/getsentry/sentry/pull/116054) +- (assemble) Validate debug ids on assemble endpoint by @Dav1dde in [#116283](https://github.com/getsentry/sentry/pull/116283) +- (autofix) Remove the organizations:autofix-on-explorer feature flag by @chromy in [#116165](https://github.com/getsentry/sentry/pull/116165) +- (billing) Bumped sentry-protos version to 0.15.0 by @krithikravi in [#116351](https://github.com/getsentry/sentry/pull/116351) +- (billing-platform) Bump sentry-protos 0.21.0 by @brendanhsentry in [#116539](https://github.com/getsentry/sentry/pull/116539) +- (bitbucket-server) Remove legacy pipeline setup views by @evanpurkhiser in [#116489](https://github.com/getsentry/sentry/pull/116489) +- (cell) Renames proxy region metric tag to cell for clarity by @GabeVillalobos in [#116402](https://github.com/getsentry/sentry/pull/116402) +- (cells) Adds CellResolver, refactors ApiGateway to use them when special casing proxy requests by @GabeVillalobos in [#116221](https://github.com/getsentry/sentry/pull/116221) +- (codeowners) Reuse get_projects in associations endpoint by @giovanni-guidini in [#116359](https://github.com/getsentry/sentry/pull/116359) +- (conversations) Simplify conversation details endpoint by @obostjancic in [#116087](https://github.com/getsentry/sentry/pull/116087) +- (dashboards) Validate prebuilt widget layouts and lengths by @DominikB2014 in [#116217](https://github.com/getsentry/sentry/pull/116217) +- (eap) Make trace item attributes alias test less fragile by @mjq in [#116545](https://github.com/getsentry/sentry/pull/116545) +- (feature-flags) Remove `organizations:insights-ai-and-mcp-dashboard-migration` by @cvxluo in [#116450](https://github.com/getsentry/sentry/pull/116450) +- (inbound-filters) Add feature flag by @shellmayr in [#116287](https://github.com/getsentry/sentry/pull/116287) +- (ingest) Minor cleanup in issue occurrence ingestion logic by @shashjar in [#116608](https://github.com/getsentry/sentry/pull/116608) +- (issue-detection) Update badge for AI Issue Detection by @roggenkemper in [#116311](https://github.com/getsentry/sentry/pull/116311) +- (issueDetails) Migrate onDiscard to useMutation + fetchMutation by @sentry-junior in [#116157](https://github.com/getsentry/sentry/pull/116157) +- (jira) Replace legacy configure view with the pipeline redirect by @evanpurkhiser in [#116574](https://github.com/getsentry/sentry/pull/116574) +- (mcp-adoption-value-discovery) Adding utm source to mcp docs link by @Abdkhan14 in [#116202](https://github.com/getsentry/sentry/pull/116202) +- (metrics) Metric detail action menu tweaks by @nsdeschenes in [#116292](https://github.com/getsentry/sentry/pull/116292) +- (msteams) Replace legacy configure view with the pipeline redirect by @evanpurkhiser in [#116520](https://github.com/getsentry/sentry/pull/116520) +- (night-shift) Use default autofix model for night-shift runs by @chromy in [#116469](https://github.com/getsentry/sentry/pull/116469) +- (ourlogs) Switch logs pinning from context to a straightforward hook by @JoshuaKGoldberg in [#116176](https://github.com/getsentry/sentry/pull/116176) +- (rpc) Log from `_make_rpc_request` by @mjq in [#116408](https://github.com/getsentry/sentry/pull/116408) +- (scm) Remove /rate-limit endpoint from internal rate-limit computation by @cmanallen in [#116471](https://github.com/getsentry/sentry/pull/116471) +- (search-query-builder) Break up contexts by @nsdeschenes in [#116126](https://github.com/getsentry/sentry/pull/116126) +- (seer-grouping) Rm backfill url by @kddubey in [#116253](https://github.com/getsentry/sentry/pull/116253) +- (seer-slack) Remove unused flag by @leeandher in [#116683](https://github.com/getsentry/sentry/pull/116683) +- (slack) Remove assistant:write OAuth scope from Slack integration by @alexsohn1126 in [#116567](https://github.com/getsentry/sentry/pull/116567) +- (tempest) Squash migrations by @vgrozdanic in [#116679](https://github.com/getsentry/sentry/pull/116679) +- (timeSince) Migrate TimeSince to use InfoText internally by @TkDodo in [#116369](https://github.com/getsentry/sentry/pull/116369) +- (trace-items) Remove `performance-sentry-conventions-fields` by @mjq in [#116392](https://github.com/getsentry/sentry/pull/116392) +- (trace-waterfall) Drop deprecated aliases from trace meta endpoint by @cvxluo in [#116514](https://github.com/getsentry/sentry/pull/116514) +- (traces) Remove stale events-trace-light frontend references by @cvxluo in [#116523](https://github.com/getsentry/sentry/pull/116523) +- (utils) Small `SafeRolloutComparator` refactors by @lobsterkatie in [#116257](https://github.com/getsentry/sentry/pull/116257) +- (workflow-engine) Build out new registry for activities by @leeandher in [#116200](https://github.com/getsentry/sentry/pull/116200) +- (workflows) Dramatically more efficient DetectorGroup querying by @kcons in [#116441](https://github.com/getsentry/sentry/pull/116441) +- Devservices 1.4.0 by @joshuarli in [#116700](https://github.com/getsentry/sentry/pull/116700) +- Rollout semver-ordering-with-build-code by @ryan953 in [#116622](https://github.com/getsentry/sentry/pull/116622) +- Remove `relay:measurements-smart-conversion` feature by @loewenheim in [#116615](https://github.com/getsentry/sentry/pull/116615) +- Replace custom JEST_TEST_BALANCER env var with --testResultsProcessor by @ryan953 in [#116661](https://github.com/getsentry/sentry/pull/116661) +- Bump taskbroker-client to 0.18.0 by @getsentry-bot in [#116630](https://github.com/getsentry/sentry/pull/116630) +- Cleanup seer-config-reminder flag by @ryan953 in [#116628](https://github.com/getsentry/sentry/pull/116628) +- Fix log statement by @joseph-sentry in [#116512](https://github.com/getsentry/sentry/pull/116512) +- Bump taskbroker-client to 0.17.1 by @getsentry-bot in [#116535](https://github.com/getsentry/sentry/pull/116535) +- Bump taskbroker-client to 0.17.0 by @getsentry-bot in [#116526](https://github.com/getsentry/sentry/pull/116526) +- Delete unused options by @joshuarli in [#116409](https://github.com/getsentry/sentry/pull/116409) +- Type utils.signing.unsign return as Any by @evanpurkhiser in [#116486](https://github.com/getsentry/sentry/pull/116486) +- Add some logging by @shruthilayaj in [#116481](https://github.com/getsentry/sentry/pull/116481) +- Remove Email model by @markstory in [#116245](https://github.com/getsentry/sentry/pull/116245) +- Bump sentry-protos 0.17.0 by @brendanhsentry in [#116456](https://github.com/getsentry/sentry/pull/116456) +- Bump taskbroker-client to 0.16.0 by @getsentry-bot in [#116411](https://github.com/getsentry/sentry/pull/116411) +- Bump sentry-protos to 0.16.1 by @getsentry-bot in [#116401](https://github.com/getsentry/sentry/pull/116401) +- Delete plan migration frontend by @noahsmartin in [#116331](https://github.com/getsentry/sentry/pull/116331) +- Bump new development version by @sentry-release-bot[bot] in [c9c46150](https://github.com/getsentry/sentry/commit/c9c461506030aae3c8e58b814b746a897237eb46) + +### Other + +- fix(cells) Hide US2 in customer facing dropdowns by @markstory in [#116529](https://github.com/getsentry/sentry/pull/116529) +- Remove `organizations:scm-repositories-v2` by @cvxluo in [#116555](https://github.com/getsentry/sentry/pull/116555) +- Add new org suspension reason by @geoffg-sentry in [#116616](https://github.com/getsentry/sentry/pull/116616) +- Upgrade sentry-scm to 0.22.0 by @cmanallen in [#116585](https://github.com/getsentry/sentry/pull/116585) +- typing(release health): Remove `sentry.release_health.metrics_sessions_v2` from mypy ignore list by @shashjar in [#116442](https://github.com/getsentry/sentry/pull/116442) +- :bug: fix[gitlab]: add assignee sync diagnostics by @iamrajjoshi in [#115356](https://github.com/getsentry/sentry/pull/115356) +- feat(cells) Allow staff users to create orgs in hidden cells by @markstory in [#116503](https://github.com/getsentry/sentry/pull/116503) +- fix(cells) Add flag and display name for us2 by @markstory in [#116513](https://github.com/getsentry/sentry/pull/116513) +- Extract BoundedLRUCache into common utility module by @cmanallen in [#116527](https://github.com/getsentry/sentry/pull/116527) +- fix(typing) Remove sentry.db.postgres.base from ignore list by @markstory in [#116493](https://github.com/getsentry/sentry/pull/116493) +- deps(scm): Upgrade sentry-scm to 0.20.0 by @cmanallen in [#116499](https://github.com/getsentry/sentry/pull/116499) +- tracemetrics(perf): Add client_sample_rate to high-volume metrics by @k-fish in [#116308](https://github.com/getsentry/sentry/pull/116308) +- chore(typing) Fix typing errors in sentry.ratelimits by @markstory in [#116310](https://github.com/getsentry/sentry/pull/116310) +- revert changes to jest config from #116269 by @armcknight in [#116416](https://github.com/getsentry/sentry/pull/116416) +- chore(typing) Fix typing issues in relocations by @markstory in [#116301](https://github.com/getsentry/sentry/pull/116301) + 26.5.1 ------ diff --git a/setup.cfg b/setup.cfg index 9c0c2dcb646582..ca1a765e0181d6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = sentry -version = 26.6.0.dev0 +version = 26.5.2 description = A realtime logging and aggregation server. long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 37feae7f93f9a9..ea271c56633fc3 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -2229,7 +2229,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: SENTRY_SELF_HOSTED_ERRORS_ONLY = False # only referenced in getsentry to provide the stable beacon version # updated with scripts/bump-version.sh -SELF_HOSTED_STABLE_VERSION = "26.5.1" +SELF_HOSTED_STABLE_VERSION = "26.5.2" # Whether we should look at X-Forwarded-For header or not # when checking REMOTE_ADDR ip addresses From 27a339d88c103d589bbc610a07d91512e4e8aa9e Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Tue, 2 Jun 2026 19:56:32 +0200 Subject: [PATCH 14/46] feat(ingest): match messages in custom inbound filter (#116701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the custom-error generic inbound filter to also match `capture_message` events, not just exceptions. The condition built by `_error_message_condition` only ever inspected `event.exception.values`, globbing each exception's type and message. Message events (`capture_message`) carry no exception interface — their text lands at `event.logentry.formatted` after normalization — so they slipped past the filter entirely. This adds an opt-in `match_logentry` flag to `_error_message_condition`. When set, type-less patterns `(None, message)` additionally glob `event.logentry.formatted`, and the result is wrapped in a top-level `or` of the exception branch and the message branches. `_custom_error_filter` enables it; the built-in `chunk-load-error` and `react-hydration-errors` filters keep the default (`match_logentry=False`), so their generated Relay config is byte-for-byte unchanged. A logentry message has no exception type, so only type-less patterns can match a message — patterns that carry a type stay exception-only. When no type-less pattern is configured, the condition collapses back to the original exception-only shape (no empty `or` wrapper). Note: tests assert the generated `RuleCondition` structure and simulate glob matching locally; they do not exercise real Relay evaluation of `event.logentry.formatted`. That field is the canonical normalized message path used elsewhere in Sentry (grouping, json pruning), so the name is correct, but full end-to-end confirmation would require sending a message event through Relay with the filter configured. --- src/sentry/ingest/inbound_filters.py | 32 +++++- tests/sentry/ingest/test_inbound_filters.py | 106 +++++++++++++++++++- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/src/sentry/ingest/inbound_filters.py b/src/sentry/ingest/inbound_filters.py index 6dedfe7fdb18a3..b587e070621f51 100644 --- a/src/sentry/ingest/inbound_filters.py +++ b/src/sentry/ingest/inbound_filters.py @@ -300,11 +300,19 @@ class _LegacyBrowserFilterSerializer(_FilterSerializer): ) -def _error_message_condition(values: Sequence[tuple[str | None, str | None]]) -> RuleCondition: +def _error_message_condition( + values: Sequence[tuple[str | None, str | None]], + match_logentry: bool = False, +) -> RuleCondition: """ Condition that expresses error message matching for an inbound filter. + + When ``match_logentry`` is set, type-less patterns (``(None, message)``) also match + the event's ``logentry.formatted`` message, so events captured via ``capture_message`` + (which carry no exception interface) are covered in addition to exceptions. """ conditions = [] + message_conditions: list[RuleCondition] = [] for ty, value in values: ty_and_value: list[RuleCondition] = [] @@ -324,7 +332,14 @@ def _error_message_condition(values: Sequence[tuple[str | None, str | None]]) -> } ) - return cast( + # A logentry message has no exception type, so only type-less patterns can match + # it. Glob the formatted message string directly. + if match_logentry and ty is None and value is not None: + message_conditions.append( + {"op": "glob", "name": "event.logentry.formatted", "value": [value]} + ) + + exception_condition = cast( RuleCondition, { "op": "any", @@ -336,6 +351,17 @@ def _error_message_condition(values: Sequence[tuple[str | None, str | None]]) -> }, ) + if not message_conditions: + return exception_condition + + return cast( + RuleCondition, + { + "op": "or", + "inner": [exception_condition, *message_conditions], + }, + ) + def _chunk_load_error_filter() -> RuleCondition: """ @@ -365,7 +391,7 @@ def _custom_error_filter() -> RuleCondition | None: # values are configured. Return None so it is omitted from the Relay config entirely. if not values: return None - return _error_message_condition(values) + return _error_message_condition(values, match_logentry=True) def _hydration_error_filter() -> RuleCondition: diff --git a/tests/sentry/ingest/test_inbound_filters.py b/tests/sentry/ingest/test_inbound_filters.py index fcc105c40b622b..fcc61b08ef3f87 100644 --- a/tests/sentry/ingest/test_inbound_filters.py +++ b/tests/sentry/ingest/test_inbound_filters.py @@ -2,7 +2,12 @@ from django.test import override_settings from sentry_relay.processing import is_glob_match -from sentry.ingest.inbound_filters import _custom_error_filter, get_generic_filters +from sentry.ingest.inbound_filters import ( + _chunk_load_error_filter, + _custom_error_filter, + _error_message_condition, + get_generic_filters, +) from sentry.models.project import Project from sentry.testutils.pytest.fixtures import django_db_all @@ -27,6 +32,19 @@ def exception_matches_filters( return False +def message_matches_filters( + message: str, + patterns: list[tuple[str | None, str | None]], +) -> bool: + """Matching rules for logentry messages: only type-less patterns apply.""" + for type_pattern, message_pattern in patterns: + if type_pattern is not None or message_pattern is None: + continue + if is_glob_match(message, message_pattern): + return True + return False + + def test_custom_error_filter_empty() -> None: # With no custom values configured, the filter is a no-op and must be omitted from # the Relay config entirely rather than emitting an empty condition. @@ -41,6 +59,49 @@ def test_custom_error_filter_builds_one_rule_per_pattern() -> None: with override_settings(SENTRY_INBOUND_FILTER_CUSTOM_VALUES=CUSTOM_PATTERNS): condition = _custom_error_filter() + # The custom filter matches exceptions AND messages: the exception branch iterates + # over event.exception.values, while type-less patterns also glob the logentry message. + assert condition == { + "op": "or", + "inner": [ + { + "op": "any", + "name": "event.exception.values", + "inner": { + "op": "or", + "inner": [ + { + "op": "and", + "inner": [ + {"op": "glob", "name": "ty", "value": ["MyError"]}, + { + "op": "glob", + "name": "value", + "value": ["Something went wrong *"], + }, + ], + }, + {"op": "glob", "name": "value", "value": ["*known flaky test*"]}, + ], + }, + }, + { + "op": "glob", + "name": "event.logentry.formatted", + "value": ["*known flaky test*"], + }, + ], + } + + +def test_custom_error_filter_exception_only_patterns_omit_logentry_branch() -> None: + # Without any type-less pattern there is nothing that can match a message, so the + # filter collapses back to the plain exception condition. + with override_settings( + SENTRY_INBOUND_FILTER_CUSTOM_VALUES=[("MyError", "Something went wrong *")] + ): + condition = _custom_error_filter() + assert condition == { "op": "any", "name": "event.exception.values", @@ -54,12 +115,34 @@ def test_custom_error_filter_builds_one_rule_per_pattern() -> None: {"op": "glob", "name": "value", "value": ["Something went wrong *"]}, ], }, - {"op": "glob", "name": "value", "value": ["*known flaky test*"]}, ], }, } +def test_chunk_load_filter_unchanged_by_logentry_matching() -> None: + # Built-in filters must not gain message matching: they keep the exception-only shape. + condition = _chunk_load_error_filter() + + # An "any" top level (rather than the "or" wrapper) means there is no logentry branch. + assert condition["op"] == "any" + assert "event.logentry.formatted" not in repr(condition) + + +def test_error_message_condition_logentry_disabled_by_default() -> None: + # The logentry branch is opt-in; the default keeps the historical exception-only shape. + condition = _error_message_condition([(None, "*known flaky test*")]) + + assert condition == { + "op": "any", + "name": "event.exception.values", + "inner": { + "op": "or", + "inner": [{"op": "glob", "name": "value", "value": ["*known flaky test*"]}], + }, + } + + @django_db_all def test_custom_error_filter_omitted_without_custom_values(default_project: Project) -> None: # The option is enabled by default for all projects, but with no configured custom @@ -103,3 +186,22 @@ def test_custom_error_filter_matches_concrete_messages( _custom_error_filter() assert exception_matches_filters(exc_type, exc_message, CUSTOM_PATTERNS) is expected + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + # Type-less pattern matches a plain message (capture_message) event. + ("This is a known flaky test timeout", True), + ("Something else entirely", False), + # Patterns that carry an exception type cannot match a type-less message. + ("Something went wrong in checkout", False), + ], +) +def test_custom_error_filter_matches_concrete_messages_via_logentry( + message: str, expected: bool +) -> None: + with override_settings(SENTRY_INBOUND_FILTER_CUSTOM_VALUES=CUSTOM_PATTERNS): + _custom_error_filter() + + assert message_matches_filters(message, CUSTOM_PATTERNS) is expected From 4670cb7c350dc43dd1ed886ac2e28d2f625de565 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 2 Jun 2026 10:57:34 -0700 Subject: [PATCH 15/46] chore: Rollout root-cause-stopping-point (#116623) Co-authored-by: Sofia Rest <68917129+srest2021@users.noreply.github.com> --- src/sentry/features/temporary.py | 2 -- src/sentry/seer/autofix/utils.py | 8 +++++--- .../core/endpoints/test_organization_details.py | 12 +++--------- ..._organization_autofix_automation_settings.py | 17 ++++++++--------- tests/sentry/tasks/seer/test_autofix.py | 14 ++------------ 5 files changed, 18 insertions(+), 35 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index b49cff364d5f8c..45d8ab46729c5c 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -278,8 +278,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:seer-explorer-ui-tools", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable structured LLM context (JSON snapshot) instead of ASCII DOM snapshot manager.add("organizations:context-engine-structured-page-context", 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 autofix introspection for early stopping of autofix runs manager.add("organizations:seer-autofix-introspection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Show Seer run ID in Slack notification footers diff --git a/src/sentry/seer/autofix/utils.py b/src/sentry/seer/autofix/utils.py index 75b48efb59eb98..1e0dd1c6c08478 100644 --- a/src/sentry/seer/autofix/utils.py +++ b/src/sentry/seer/autofix/utils.py @@ -94,9 +94,11 @@ 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) + valid = { + AutofixStoppingPoint.CODE_CHANGES, + AutofixStoppingPoint.OPEN_PR, + AutofixStoppingPoint.ROOT_CAUSE, + } return valid diff --git a/tests/sentry/core/endpoints/test_organization_details.py b/tests/sentry/core/endpoints/test_organization_details.py index e0b9736e7b6953..e5c7ac64ddba93 100644 --- a/tests/sentry/core/endpoints/test_organization_details.py +++ b/tests/sentry/core/endpoints/test_organization_details.py @@ -677,7 +677,7 @@ 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") + self.organization.update_option("sentry:default_automated_run_stopping_point", "foo-bar") response = self.get_success_response(self.organization.slug) assert ( response.data["defaultAutomatedRunStoppingPoint"] @@ -1643,24 +1643,18 @@ def test_default_automated_run_stopping_point_default(self) -> None: ) def test_default_automated_run_stopping_point_can_be_set(self) -> None: - for choice in ("code_changes", "open_pr"): + for choice in ("code_changes", "open_pr", "root_cause"): with self.subTest(choice=choice): data = {"defaultAutomatedRunStoppingPoint": choice} response = self.get_success_response(self.organization.slug, **data) assert response.data["defaultAutomatedRunStoppingPoint"] == choice def test_default_automated_run_stopping_point_rejects_invalid(self) -> None: - for invalid in ("solution", "invalid_point", "root_cause"): + for invalid in ("solution", "invalid_point"): 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/endpoints/test_organization_autofix_automation_settings.py b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py index a32e53683f338f..037cb1f4f1a194 100644 --- a/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py +++ b/tests/sentry/seer/endpoints/test_organization_autofix_automation_settings.py @@ -273,17 +273,16 @@ def test_post_rejects_invalid_stopping_point(self) -> None: ) assert response.status_code == 400 - def test_post_accepts_root_cause_stopping_point_with_flag(self) -> None: + def test_post_accepts_root_cause_stopping_point(self) -> None: project = self.create_project(organization=self.organization) - with self.feature("organizations:root-cause-stopping-point"): - response = self.client.post( - self.url, - { - "projectIds": [project.id], - "automatedRunStoppingPoint": "root_cause", - }, - ) + response = self.client.post( + self.url, + { + "projectIds": [project.id], + "automatedRunStoppingPoint": "root_cause", + }, + ) assert response.status_code == 204 assert project.get_option("sentry:seer_automated_run_stopping_point") == "root_cause" diff --git a/tests/sentry/tasks/seer/test_autofix.py b/tests/sentry/tasks/seer/test_autofix.py index d254bd6aa49d51..795402ca369bd7 100644 --- a/tests/sentry/tasks/seer/test_autofix.py +++ b/tests/sentry/tasks/seer/test_autofix.py @@ -254,8 +254,8 @@ def test_project_with_invalid_stopping_point_gets_org_default_stopping_point(sel self.organization.update_option("sentry:seer_default_coding_agent_integration_id", 42) self.organization.update_option("sentry:default_automated_run_stopping_point", "open_pr") - # "root_cause" is not in the valid set without the root-cause-stopping-point flag. - project.update_option("sentry:seer_automated_run_stopping_point", "root_cause") + # "solution" is not in the valid set for seat-based orgs. + project.update_option("sentry:seer_automated_run_stopping_point", "solution") project.update_option("sentry:seer_automation_handoff_point", "root_cause") project.update_option("sentry:seer_automation_handoff_target", "claude_code_agent") project.update_option("sentry:seer_automation_handoff_integration_id", 99) @@ -269,16 +269,6 @@ def test_project_with_invalid_stopping_point_gets_org_default_stopping_point(sel assert project.get_option("sentry:seer_automation_handoff_target") == "claude_code_agent" assert project.get_option("sentry:seer_automation_handoff_integration_id") == 99 - def test_root_cause_stopping_point_preserved_when_valid(self) -> None: - """Project with root_cause stopping point is preserved when root-cause-stopping-point flag is enabled.""" - project = self.create_project(organization=self.organization) - project.update_option("sentry:seer_automated_run_stopping_point", "root_cause") - - with self.feature("organizations:root-cause-stopping-point"): - configure_seer_for_existing_org(organization_id=self.organization.id) - - assert project.get_option("sentry:seer_automated_run_stopping_point") == "root_cause" - def test_sets_seat_based_tier_cache_to_true(self) -> None: """Test that the seat-based tier cache is set to True after configuring org.""" self.create_project(organization=self.organization) From 2fd27e5aeb2f23bedcdce08690813d10f1d8c715 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 2 Jun 2026 11:02:41 -0700 Subject: [PATCH 16/46] ref(flags): Graduate organizations:code-review-experiments-enabled (#116657) GA flag rolled out to 100%. Remove the flag and make the gated behavior unconditional. --- src/sentry/features/temporary.py | 2 -- src/sentry/seer/code_review/utils.py | 17 +-------- tests/sentry/seer/code_review/test_utils.py | 40 +-------------------- 3 files changed, 2 insertions(+), 57 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 45d8ab46729c5c..08c9a47c991383 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -56,8 +56,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:auto-link-repos-by-name", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enabled for orgs that participated in the code review beta manager.add("organizations:code-review-beta", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable A/B testing experiments for code review (org eligibility) - manager.add("organizations:code-review-experiments-enabled", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable continuous profiling manager.add("organizations:continuous-profiling", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the ingestion of profile functions metrics into EAP diff --git a/src/sentry/seer/code_review/utils.py b/src/sentry/seer/code_review/utils.py index 22520ca217d7d2..587d13e12b96cc 100644 --- a/src/sentry/seer/code_review/utils.py +++ b/src/sentry/seer/code_review/utils.py @@ -11,7 +11,6 @@ from django.conf import settings from urllib3.exceptions import HTTPError -from sentry import features from sentry.integrations.github.client import GitHubReaction from sentry.integrations.github.utils import is_github_rate_limit_sensitive from sentry.integrations.github.webhook_types import GithubWebhookType @@ -274,9 +273,8 @@ def _common_codegen_request_payload( }, } - # Add experiment_enabled flag ONLY for pr-review requests (not for pr-closed / pr-reopened) if add_experiment_enabled: - data["experiment_enabled"] = is_org_enabled_for_code_review_experiments(organization) + data["experiment_enabled"] = True return { "external_owner_id": repo.external_id, @@ -624,16 +622,3 @@ def delete_existing_reactions_and_add_reaction( CodeReviewErrorType.REACTION_FAILED, ) logger.warning(Log.REACTION_FAILED.value, exc_info=True) - - -def is_org_enabled_for_code_review_experiments(organization: Organization) -> bool: - """ - Checks if an org is eligible to code review experiments via Flagpole. - - If True the exact experiment is decided by Seer. - If False no experiment will be applied to the PR, and it'll use the default behavior. - """ - return features.has( - "organizations:code-review-experiments-enabled", - organization, - ) diff --git a/tests/sentry/seer/code_review/test_utils.py b/tests/sentry/seer/code_review/test_utils.py index 34d9081abf1a30..fee389bb74c60f 100644 --- a/tests/sentry/seer/code_review/test_utils.py +++ b/tests/sentry/seer/code_review/test_utils.py @@ -20,12 +20,10 @@ _get_trigger_metadata_for_pull_request, convert_enum_keys_to_strings, get_tags, - is_org_enabled_for_code_review_experiments, transform_webhook_to_codegen_request, ) from sentry.testutils.cases import TestCase from sentry.testutils.factories import Factories -from sentry.testutils.helpers.features import with_feature from sentry.users.models.user import User from sentry.utils import json @@ -414,7 +412,6 @@ def test_issue_comment_payload_is_json_serializable( # This would fail if trigger_at is a datetime object instead of string json.dumps(result) # Should not raise TypeError - @with_feature("organizations:code-review-experiments-enabled") def test_pr_closed_does_not_include_experiment_enabled( self, setup_entities: tuple[User, Organization, Project, Repository], @@ -437,7 +434,6 @@ def test_pr_closed_does_not_include_experiment_enabled( assert result is not None assert "experiment_enabled" not in result["data"] - @with_feature("organizations:code-review-experiments-enabled") def test_issue_comment_includes_experiment_enabled( self, setup_entities: tuple[User, Organization, Project, Repository], @@ -463,8 +459,7 @@ def test_issue_comment_includes_experiment_enabled( assert result is not None assert result["data"]["experiment_enabled"] is True - @with_feature("organizations:code-review-experiments-enabled") - def test_pr_review_includes_experiment_enabled_when_feature_enabled( + def test_pr_review_includes_experiment_enabled_always_true( self, setup_entities: tuple[User, Organization, Project, Repository], ) -> None: @@ -486,28 +481,6 @@ def test_pr_review_includes_experiment_enabled_when_feature_enabled( assert result is not None assert result["data"]["experiment_enabled"] is True - def test_pr_review_includes_experiment_enabled_false_when_feature_disabled( - self, - setup_entities: tuple[User, Organization, Project, Repository], - ) -> None: - _, organization, _, repo = setup_entities - - event_payload = { - "pull_request": {"number": 42}, - "sender": {"login": "test-user"}, - } - result = transform_webhook_to_codegen_request( - GithubWebhookType.PULL_REQUEST, - "opened", - event_payload, - organization, - repo, - "abc123sha", - ) - - assert result is not None - assert result["data"]["experiment_enabled"] is False - class TestExtractGithubInfo(TestCase): def setUp(self) -> None: @@ -882,17 +855,6 @@ def test_handles_seer_code_review_feature_enum(self) -> None: assert isinstance(list(result.keys())[0], str) -class CodeReviewExperimentAssignmentTest(TestCase): - def test_enabled(self) -> None: - org = self.create_organization(slug="test-org") - with self.feature("organizations:code-review-experiments-enabled"): - assert is_org_enabled_for_code_review_experiments(org) - - def test_disabled(self) -> None: - org = self.create_organization(slug="test-org") - assert not is_org_enabled_for_code_review_experiments(org) - - class TestBuildRepoDefinition: def _make_repo(self, provider: str = "integrations:github") -> MagicMock: repo = MagicMock() From 9325312604eb95a735729d06384660dd63c217d5 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 2 Jun 2026 13:03:52 -0500 Subject: [PATCH 17/46] feat(onboarding): Scaffold SCM-first project creation as a single view (#116577) ## TL;DR Scaffolds the SCM-first project creation surface at `/projects/new/` as a single view with progressive disclosure. Section 1 (Connect a repository) is always visible. Sections 2 and 3 (Platform & features, Project details) reveal together once a meaningful action is taken on section 1. Gated on `organizations:onboarding-scm-project-creation-experiment`. Orgs not in the experiment see the legacy `CreateProject` form. Section bodies are placeholders; VDY-74/75/76 will wire the decoupled SCM components from VDY-72. --- ## Stack - [PR 1](https://github.com/getsentry/sentry/pull/116434): Make decoupled SCM components flow-aware for analytics (base) - **PR 2 (this):** Scaffold SCM-first project creation as a single view - PR 3 (next): Wire `ScmConnect` into the single-view scaffold (VDY-74) ## Summary - Adds `ScmCreateProject` at `static/app/views/projectInstall/scmCreateProject.tsx`. Single view, no `:step` route param, no `useNavigate`, no `Stepper`. - Wizard state lives in `useSessionStorage` under the key `project-creation-wizard`, separate from new-org onboarding's `onboarding` key so the two flows do not collide. A single `repoStepCompleted` boolean drives the reveal. - Section 1 always renders. Sections 2 and 3 render together when `repoStepCompleted` is true. The flag is decoupled from selection state, so de-selecting a repo later does not collapse the rest of the page. - `newProject.tsx` gates on the experiment and dispatches to `ScmCreateProject` when in the experiment, else falls through to the existing `CreateProject` form. No route changes; the existing `/projects/new/` route handles everything. - Section bodies are placeholders. Section 1 has a temporary Continue button so the disclosure is testable; that button goes away when VDY-74 wires up ``. ## Out of scope - Wiring ``, ``, `` into the three sections. VDY-74/75/76 will pick those up. - Funnel construction in Amplitude. BI work. Refs VDY-73 ## Test plan - [x] `pnpm typecheck` clean - [x] `pnpm lint:js` clean on touched files - [ ] With the experiment flag on locally, `/projects/new/` renders the single-view scaffold with only section 1 on first load - [ ] Clicking the placeholder Continue button on section 1 reveals sections 2 and 3 together - [ ] Refreshing the page preserves the revealed sections (sessionStorage round-trip) - [ ] With the flag off, `/projects/new/` renders the legacy `CreateProject` form unchanged --- .../app/views/projectInstall/newProject.tsx | 11 ++ .../views/projectInstall/scmCreateProject.tsx | 108 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 static/app/views/projectInstall/scmCreateProject.tsx diff --git a/static/app/views/projectInstall/newProject.tsx b/static/app/views/projectInstall/newProject.tsx index cc421c134bb87f..a6ddfa975886e6 100644 --- a/static/app/views/projectInstall/newProject.tsx +++ b/static/app/views/projectInstall/newProject.tsx @@ -3,10 +3,21 @@ import styled from '@emotion/styled'; import {Stack} from '@sentry/scraps/layout'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; +import {useExperiment} from 'sentry/utils/useExperiment'; import {CreateProject} from './createProject'; +import {ScmCreateProject} from './scmCreateProject'; function NewProject() { + const {inExperiment: hasScmProjectCreation} = useExperiment({ + feature: 'onboarding-scm-project-creation-experiment', + reportExposure: true, + }); + + if (hasScmProjectCreation) { + return ; + } + return ( diff --git a/static/app/views/projectInstall/scmCreateProject.tsx b/static/app/views/projectInstall/scmCreateProject.tsx new file mode 100644 index 00000000000000..76a715e6fb3ab4 --- /dev/null +++ b/static/app/views/projectInstall/scmCreateProject.tsx @@ -0,0 +1,108 @@ +import {Fragment, useCallback} from 'react'; + +import {Button} from '@sentry/scraps/button'; +import {Container, Stack} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {Access} from 'sentry/components/acl/access'; +import * as Layout from 'sentry/components/layouts/thirds'; +import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; +import {t, tct} from 'sentry/locale'; +import {useCanCreateProject} from 'sentry/utils/useCanCreateProject'; +import {useSessionStorage} from 'sentry/utils/useSessionStorage'; + +interface WizardState { + // Flips true on the first meaningful action in section 1 (repo selected + // or "Continue without a repo" clicked). Sections 2 and 3 reveal together + // when this is true. Decoupled from selection state so later state edits + // (de-selecting a repo) do not collapse the rest of the page. + repoStepCompleted: boolean; +} + +const INITIAL_STATE: WizardState = { + repoStepCompleted: false, +}; + +export function ScmCreateProject() { + // Session-storage backed so a refresh restores how far the user has + // progressed. Separate key from new-org onboarding's 'onboarding' key. + const [state, setState] = useSessionStorage('project-creation-wizard', INITIAL_STATE); + const canUserCreateProject = useCanCreateProject(); + + const completeRepoStep = useCallback(() => { + setState(s => ({...s, repoStepCompleted: true})); + }, [setState]); + + return ( + + + + {t('Create a new project')} + + + {tct( + 'Set up a separate project for each part of your application (for example, your API server and frontend client), to quickly pinpoint which part of your application errors are coming from. [link: Read the docs].', + { + link: ( + + ), + } + )} + + + + + + {state.repoStepCompleted && ( + + + + + )} + + + + ); +} + +// Placeholder for VDY-74. Will be replaced with . +function ConnectRepositorySection({onComplete}: {onComplete: () => void}) { + return ( + + + {t('Connect a repository')} + + {t('Connect step content goes here (VDY-74).')} + + + ); +} + +// Placeholder for VDY-75. Will be replaced with . +function PlatformFeaturesSection() { + return ( + + + {t('Platform & features')} + + + {t('Platform and features step content goes here (VDY-75).')} + + + ); +} + +// Placeholder for VDY-76. Will be replaced with . +function ProjectDetailsSection() { + return ( + + + {t('Project details')} + + {t('Project details step content goes here (VDY-76).')} + + ); +} From c7da4d4ae9841d0166159492bef6c09b0452545d Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 2 Jun 2026 11:15:46 -0700 Subject: [PATCH 18/46] fix(preprod): Reap stuck PROCESSING snapshot comparisons (#116708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the hourly `detect_expired_preprod_artifacts` reaper to also mark `PreprodSnapshotComparison` rows stuck in `PROCESSING` for more than 30 minutes as `FAILED` + `TIMEOUT`, mirroring the existing `PreprodArtifactSizeComparison` handling. When a `compare_snapshots` task is hard-killed mid-run — SIGKILL after a deploy/worker SIGTERM, or OOM — the `except BaseException` cleanup that would set the row to `FAILED` never runs, so the row is left stuck in `PROCESSING`. The task's retry guard only re-runs `PENDING`/`FAILED` rows, and the task is only enqueued on a build upload or a staff recompare — never on page load/poll. So a stuck comparison is skipped forever (`skipping, comparison not in retryable state (state=1)`) and spins indefinitely for the customer. Marking it `FAILED` makes the row retryable again on the next upload or recompare, and turns the perpetual spinner into an honest failed state. This matches how the sibling `PreprodArtifactSizeComparison` PROCESSING rows are already recovered. This is fix #3 of a two-PR Graphite stack. The follow-up PR (#1) parallelizes the odiff comparison phase so large comparisons finish well within the deadline — that's the durable fix for the underlying slowness; this PR stops affected comparisons from getting permanently wedged in the meantime. Co-authored-by: Claude --- src/sentry/preprod/tasks.py | 33 ++++++++++++- tests/sentry/preprod/test_tasks.py | 75 ++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/sentry/preprod/tasks.py b/src/sentry/preprod/tasks.py index b8240a1d51bfb2..8f40639bb7ecf1 100644 --- a/src/sentry/preprod/tasks.py +++ b/src/sentry/preprod/tasks.py @@ -26,6 +26,7 @@ PreprodArtifactSizeComparison, PreprodArtifactSizeMetrics, PreprodBuildConfiguration, + PreprodSnapshotComparison, ) from sentry.preprod.quotas import ( has_installable_quota, @@ -835,6 +836,7 @@ def detect_expired_preprod_artifacts() -> None: - PreprodArtifacts that have been processing for more than 30 minutes - PreprodArtifactSizeMetrics that have been in progress for more than 30 minutes - PreprodArtifactSizeComparisons that have been in progress for more than 30 minutes + - PreprodSnapshotComparisons that have been in progress for more than 30 minutes """ current_time = timezone.now() timeout_threshold = current_time - datetime.timedelta(minutes=30) @@ -956,15 +958,44 @@ def detect_expired_preprod_artifacts() -> None: ) expired_size_comparisons_count = 0 + # Find expired PreprodSnapshotComparisons (those in PROCESSING state for more than 30 minutes) + # Note: ignore snapshot comparisons in a pending state + expired_snapshot_comparisons = PreprodSnapshotComparison.objects.filter( + state=PreprodSnapshotComparison.State.PROCESSING, date_updated__lte=timeout_threshold + ) + + try: + with transaction.atomic(router.db_for_write(PreprodSnapshotComparison)): + expired_snapshot_comparisons_count = expired_snapshot_comparisons.update( + state=PreprodSnapshotComparison.State.FAILED, + error_code=PreprodSnapshotComparison.ErrorCode.TIMEOUT, + error_message="Snapshot comparison processing timed out after 30 minutes", + ) + + if expired_snapshot_comparisons_count > 0: + logger.info( + "preprod.tasks.detect_expired_preprod_artifacts.batch_updated_expired_snapshot_comparisons_as_failed", + extra={ + "expired_snapshot_comparisons_count": expired_snapshot_comparisons_count, + }, + ) + except Exception: + logger.exception( + "preprod.tasks.detect_expired_preprod_artifacts.failed_to_batch_update_expired_snapshot_comparisons", + ) + expired_snapshot_comparisons_count = 0 + logger.info( "preprod.tasks.detect_expired_preprod_artifacts.completed", extra={ "expired_artifacts_count": expired_artifacts_count, "expired_size_metrics_count": expired_size_metrics_count, "expired_size_comparisons_count": expired_size_comparisons_count, + "expired_snapshot_comparisons_count": expired_snapshot_comparisons_count, "total_expired_count": expired_artifacts_count + expired_size_metrics_count - + expired_size_comparisons_count, + + expired_size_comparisons_count + + expired_snapshot_comparisons_count, }, ) diff --git a/tests/sentry/preprod/test_tasks.py b/tests/sentry/preprod/test_tasks.py index f0f50cf65c67ef..65da252f3bf93e 100644 --- a/tests/sentry/preprod/test_tasks.py +++ b/tests/sentry/preprod/test_tasks.py @@ -17,6 +17,7 @@ PreprodArtifactSizeComparison, PreprodArtifactSizeMetrics, PreprodBuildConfiguration, + PreprodSnapshotComparison, ) from sentry.preprod.tasks import ( assemble_preprod_artifact, @@ -1133,6 +1134,80 @@ def test_detect_expired_preprod_artifacts_with_expired(self) -> None: and "30 minutes" in expired_size_comparison.error_message ) + def _create_snapshot_comparison(self, state: int) -> PreprodSnapshotComparison: + head_artifact = self.create_preprod_artifact( + project=self.project, + state=PreprodArtifact.ArtifactState.PROCESSED, + ) + base_artifact = self.create_preprod_artifact( + project=self.project, + state=PreprodArtifact.ArtifactState.PROCESSED, + ) + head_metrics = self.create_preprod_snapshot_metrics(head_artifact) + base_metrics = self.create_preprod_snapshot_metrics(base_artifact) + return self.create_preprod_snapshot_comparison( + head_snapshot_metrics=head_metrics, + base_snapshot_metrics=base_metrics, + state=state, + ) + + def test_detect_expired_preprod_artifacts_expires_stuck_snapshot_comparison(self) -> None: + """A snapshot comparison stuck in PROCESSING for >30 minutes is marked FAILED""" + old_time = timezone.now() - timedelta(minutes=35) + + comparison = self._create_snapshot_comparison( + state=PreprodSnapshotComparison.State.PROCESSING + ) + PreprodSnapshotComparison.objects.filter(id=comparison.id).update(date_updated=old_time) + + detect_expired_preprod_artifacts() + + comparison.refresh_from_db() + assert comparison.state == PreprodSnapshotComparison.State.FAILED + assert comparison.error_code == PreprodSnapshotComparison.ErrorCode.TIMEOUT + assert comparison.error_message and "30 minutes" in comparison.error_message + + def test_detect_expired_preprod_artifacts_ignores_recent_snapshot_comparison(self) -> None: + """A snapshot comparison that started PROCESSING recently is left alone""" + comparison = self._create_snapshot_comparison( + state=PreprodSnapshotComparison.State.PROCESSING + ) + + detect_expired_preprod_artifacts() + + comparison.refresh_from_db() + assert comparison.state == PreprodSnapshotComparison.State.PROCESSING + + def test_detect_expired_preprod_artifacts_ignores_stale_non_processing_snapshot_comparison( + self, + ) -> None: + """An old snapshot comparison that is not PROCESSING is left alone""" + old_time = timezone.now() - timedelta(minutes=35) + + comparison = self._create_snapshot_comparison(state=PreprodSnapshotComparison.State.SUCCESS) + PreprodSnapshotComparison.objects.filter(id=comparison.id).update(date_updated=old_time) + + detect_expired_preprod_artifacts() + + comparison.refresh_from_db() + assert comparison.state == PreprodSnapshotComparison.State.SUCCESS + assert comparison.error_code is None + + def test_detect_expired_preprod_artifacts_ignores_stale_pending_snapshot_comparison( + self, + ) -> None: + """An old snapshot comparison still in PENDING is left alone""" + old_time = timezone.now() - timedelta(minutes=35) + + comparison = self._create_snapshot_comparison(state=PreprodSnapshotComparison.State.PENDING) + PreprodSnapshotComparison.objects.filter(id=comparison.id).update(date_updated=old_time) + + detect_expired_preprod_artifacts() + + comparison.refresh_from_db() + assert comparison.state == PreprodSnapshotComparison.State.PENDING + assert comparison.error_code is None + def test_detect_expired_preprod_artifacts_captures_sentry_message(self) -> None: """Test that Sentry messages are captured for each expired artifact""" current_time = timezone.now() From 5ae1c6141d6ddaff597c68f6ee53587be4d5bfa6 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Tue, 2 Jun 2026 14:23:42 -0400 Subject: [PATCH 19/46] fix(txn-summary): Clean up `getEAPTotalsEventView` (#116711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `percentile` function doesn't exist in EAP so this call always failed. The screen works correctly without it, so remove the call and the (also always failing) attempt to read the results. We can rely on `isSummaryViewFrontendPageLoad` and the default state of the performance score widget alone for this. Further, the `count_unique(user)` result was never read, so remove that query too. --- 🤖: Nope. --- .../transactionOverview/content.tsx | 9 +-------- .../transactionOverview/index.tsx | 18 +----------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/static/app/views/performance/transactionSummary/transactionOverview/content.tsx b/static/app/views/performance/transactionSummary/transactionOverview/content.tsx index 8396de9250e42d..77e2a36666d7cf 100644 --- a/static/app/views/performance/transactionSummary/transactionOverview/content.tsx +++ b/static/app/views/performance/transactionSummary/transactionOverview/content.tsx @@ -59,7 +59,6 @@ import {EAPChartsWidget} from 'sentry/views/performance/transactionSummary/trans import {EAPSidebarCharts} from 'sentry/views/performance/transactionSummary/transactionOverview/eapSidebarCharts'; import {canUseTransactionMetricsData} from 'sentry/views/performance/transactionSummary/transactionOverview/utils'; import { - EAP_WEB_VITALS, makeVitalGroups, PERCENTILE as VITAL_PERCENTILE, } from 'sentry/views/performance/transactionSummary/transactionVitals/constants'; @@ -151,13 +150,7 @@ function EAPSummaryContentInner({ // NOTE: This is not a robust check for whether or not a transaction is a front end // transaction, however it will suffice for now. - const hasWebVitals = - isSummaryViewFrontendPageLoad(eventView, projects) || - (totalValues !== null && - EAP_WEB_VITALS.some(vital => { - const field = `percentile(${vital},${VITAL_PERCENTILE})`; - return Number.isFinite(totalValues[field]) && totalValues[field] !== 0; - })); + const hasWebVitals = isSummaryViewFrontendPageLoad(eventView, projects); const isFrontendView = isSummaryViewFrontend(eventView, projects); diff --git a/static/app/views/performance/transactionSummary/transactionOverview/index.tsx b/static/app/views/performance/transactionSummary/transactionOverview/index.tsx index 84a2806873ad31..6a11895834e6e7 100644 --- a/static/app/views/performance/transactionSummary/transactionOverview/index.tsx +++ b/static/app/views/performance/transactionSummary/transactionOverview/index.tsx @@ -35,7 +35,6 @@ import { import {getTransactionMEPParamsIfApplicable} from 'sentry/views/performance/transactionSummary/transactionOverview/utils'; import {useTransactionSummaryContext} from 'sentry/views/performance/transactionSummary/transactionSummaryContext'; import { - EAP_WEB_VITALS, makeVitalGroups, PERCENTILE as VITAL_PERCENTILE, } from 'sentry/views/performance/transactionSummary/transactionVitals/constants'; @@ -373,29 +372,14 @@ function getEAPTotalsEventView( _organization: Organization, eventView: EventView ): EventView { - const vitals = EAP_WEB_VITALS; - const totalsColumns: QueryFieldValue[] = [ { kind: 'function', function: ['p95', '', undefined, undefined], }, - { - kind: 'function', - function: ['count_unique', 'user', undefined, undefined], - }, ]; - return eventView.withColumns([ - ...totalsColumns, - ...vitals.map( - vital => - ({ - kind: 'function', - function: ['percentile', vital, VITAL_PERCENTILE.toString(), undefined], - }) as Column - ), - ]); + return eventView.withColumns(totalsColumns); } export default TransactionOverview; From 493aefc2088fea6a3f8a0efa236bbf1de3845eb4 Mon Sep 17 00:00:00 2001 From: Christinarlong <60594860+Christinarlong@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:24:42 -0700 Subject: [PATCH 20/46] fix(workflow): Fix type choices being a snapshot list of grouptype registry (#116637) --- src/sentry/rules/filters/issue_type.py | 44 ++++++++++--------- .../handlers/condition/issue_type_handler.py | 18 +------- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/src/sentry/rules/filters/issue_type.py b/src/sentry/rules/filters/issue_type.py index 582c85a5dc7bcc..4079967d9185e1 100644 --- a/src/sentry/rules/filters/issue_type.py +++ b/src/sentry/rules/filters/issue_type.py @@ -11,19 +11,16 @@ from sentry.types.condition_activity import ConditionActivity -def get_type_choices() -> OrderedDict[str, str]: +def get_type_choices() -> list[tuple[str, str]]: """Generate choices from all registered group types.""" - type_choices = OrderedDict() - for group_type_cls in grouptype.registry.all(): - if not group_type_cls.released: - continue - # Use slug as key, description for display - display_name = getattr(group_type_cls, "description", None) or group_type_cls.slug - type_choices[group_type_cls.slug] = display_name - return type_choices + return [ + # Use slug as value, description for display + (group_type_cls.slug, getattr(group_type_cls, "description", group_type_cls.slug)) + for group_type_cls in grouptype.registry.all() + if group_type_cls.released + ] -TYPE_CHOICES = get_type_choices() INCLUDE_CHOICES = OrderedDict([("true", "equal to"), ("false", "not equal to")]) @@ -31,23 +28,26 @@ class IssueTypeForm(forms.Form): include = forms.ChoiceField( choices=list(INCLUDE_CHOICES.items()), required=False, initial="true" ) - value = forms.ChoiceField(choices=list(TYPE_CHOICES.items())) + value = forms.ChoiceField(choices=get_type_choices) class IssueTypeFilter(EventFilter): id = "sentry.rules.filters.issue_type.IssueTypeFilter" - form_fields = { - "include": { - "type": "choice", - "choices": list(INCLUDE_CHOICES.items()), - "initial": "true", - }, - "value": {"type": "choice", "choices": list(TYPE_CHOICES.items())}, - } rule_type = "filter/event" label = "The issue's type is {include} {value}" prompt = "The issue's type is ..." + @property + def form_fields(self) -> dict: + return { + "include": { + "type": "choice", + "choices": list(INCLUDE_CHOICES.items()), + "initial": "true", + }, + "value": {"type": "choice", "choices": get_type_choices()}, + } + def _passes(self, group: Group) -> bool: try: comparison_value = self.get_option("value") @@ -82,8 +82,10 @@ def passes_activity( def render_label(self) -> str: value = self.data["value"] - title = TYPE_CHOICES.get(value) - issue_type_name = title if title else "" + # Look up the GroupType at call time so the registry is fully populated; + # GroupType.description is the human-readable display name (e.g. "Error"). + group_type = grouptype.registry.get_by_slug(value) + issue_type_name = (getattr(group_type, "description", None) or value) if group_type else "" include_label = INCLUDE_CHOICES.get(self.data.get("include", "true"), "equal to") return self.label.format(include=include_label, value=issue_type_name) diff --git a/src/sentry/workflow_engine/handlers/condition/issue_type_handler.py b/src/sentry/workflow_engine/handlers/condition/issue_type_handler.py index c3bb5011b3bd76..651b42b5c89f04 100644 --- a/src/sentry/workflow_engine/handlers/condition/issue_type_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/issue_type_handler.py @@ -8,20 +8,6 @@ from sentry.workflow_engine.registry import condition_handler_registry from sentry.workflow_engine.types import DataConditionHandler, WorkflowEventData - -def get_type_choices() -> OrderedDict[str, str]: - """Generate choices from all registered group types.""" - type_choices = OrderedDict() - for group_type_cls in grouptype.registry.all(): - if not group_type_cls.released: - continue - # Use slug as key, description for display - display_name = getattr(group_type_cls, "description", None) or group_type_cls.slug - type_choices[group_type_cls.slug] = display_name - return type_choices - - -TYPE_CHOICES = get_type_choices() INCLUDE_CHOICES = OrderedDict([("true", "equal to"), ("false", "not equal to")]) @@ -67,7 +53,7 @@ def evaluate_value(event_data: WorkflowEventData, comparison: Any) -> bool: @classmethod def render_label(cls, condition_data: dict[str, Any]) -> str: value = condition_data["value"] - title = TYPE_CHOICES.get(value) - issue_type_name = title if title else "" + group_type = grouptype.registry.get_by_slug(value) + issue_type_name = (getattr(group_type, "description", None) or value) if group_type else "" include_label = INCLUDE_CHOICES.get(condition_data.get("include", "true"), "equal to") return cls.label_template.format(include=include_label, value=issue_type_name) From c3ab05c096e70f9d8ad7cab99fb79de656d59047 Mon Sep 17 00:00:00 2001 From: "sentry-release-bot[bot]" <180476844+sentry-release-bot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:34:18 +0000 Subject: [PATCH 21/46] meta: Bump new development version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index ca1a765e0181d6..b23add74004891 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = sentry -version = 26.5.2 +version = 26.7.0.dev0 description = A realtime logging and aggregation server. long_description = file: README.md long_description_content_type = text/markdown From 1e76b08ce39e412137c505e6cc7f37e95aafbd55 Mon Sep 17 00:00:00 2001 From: William Mak Date: Tue, 2 Jun 2026 14:40:32 -0400 Subject: [PATCH 22/46] feat(events): Add a log for query errors (#116696) - As ai usage increases we're getting more query errors, log these so we can get a better idea of what kind of query errors are happening --- src/sentry/api/utils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/sentry/api/utils.py b/src/sentry/api/utils.py index cf1b55f029fd15..cc7f4ca33f195c 100644 --- a/src/sentry/api/utils.py +++ b/src/sentry/api/utils.py @@ -369,15 +369,13 @@ def handle_query_errors() -> Generator[None]: try: yield except InvalidSearchQuery as error: - message = str(error) + message = original_error = str(error) # Special case the project message since it has so many variants so tagging is messy otherwise if message.endswith("do not exist or are not actively selected."): - sentry_sdk.set_tag( - "query.error_reason", "Project in query does not exist or not selected" - ) - else: - sentry_sdk.set_tag("query.error_reason", message) - raise ParseError(detail=message) + message = "Project in query does not exist or not selected" + sentry_sdk.set_tag("query.error_reason", message) + logger.info("A query error was handled", extra={"query.error_reason": message}) + raise ParseError(detail=original_error) except ArithmeticError as error: message = str(error) sentry_sdk.set_tag("query.error_reason", message) From 9ecf79609cb2d035b08e0c612950ed8997a36549 Mon Sep 17 00:00:00 2001 From: Shashank Jarmale Date: Tue, 2 Jun 2026 11:47:25 -0700 Subject: [PATCH 23/46] chore(typing): Remove `sentry.search.events.builder.errors` from mypy ignore list (#116621) Resolves [ENG-6452](https://linear.app/getsentry/issue/ENG-6452/remove-sentrysearcheventsbuildererrors-from-ignore-list). Removes `sentry.search.events.builder.errors` from the mypy ignore list. - Gives `ErrorsQueryBuilderMixin` an explicit base class (`BaseQueryBuilder`) so mypy can resolve attribute accesses and `super()` calls - Add `use_entity_prefix_for_fields` to `DatasetConfig` base class - Add a `Sequence[ParsedTerm]` overload to `convert_query_values` (and widen its internal helpers) --- pyproject.toml | 1 - src/sentry/issues/issue_search.py | 28 +++++++++++++++++----- src/sentry/search/events/builder/errors.py | 3 ++- src/sentry/search/events/datasets/base.py | 1 + 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 81cf099c291d12..9b407dc51d8197 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -390,7 +390,6 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = [ "sentry.api.endpoints.organization_releases", - "sentry.search.events.builder.errors", "sentry.search.events.builder.metrics", "sentry.search.events.datasets.filter_aliases", "sentry.snuba.metrics.query_builder", diff --git a/src/sentry/issues/issue_search.py b/src/sentry/issues/issue_search.py index 02b49baa908758..4ec8feef14269d 100644 --- a/src/sentry/issues/issue_search.py +++ b/src/sentry/issues/issue_search.py @@ -30,7 +30,7 @@ from sentry.models.project import Project from sentry.models.team import Team from sentry.search.events.constants import EQUALITY_OPERATORS, INEQUALITY_OPERATORS -from sentry.search.events.filter import to_list +from sentry.search.events.filter import ParsedTerm, to_list from sentry.search.utils import ( DEVICE_CLASS, get_teams_for_users, @@ -352,14 +352,25 @@ def convert_query_values( ) -> Sequence[QueryToken]: ... +@overload def convert_query_values( - search_filters: Sequence[QueryToken], + search_filters: Sequence[ParsedTerm], projects: Sequence[Project], user: User | RpcUser | AnonymousUser | None, environments: Sequence[Environment] | None, value_converters=value_converters, allow_aggregate_filters=False, -) -> Sequence[QueryToken]: +) -> Sequence[ParsedTerm]: ... + + +def convert_query_values( + search_filters: Sequence[QueryToken | str], + projects: Sequence[Project], + user: User | RpcUser | AnonymousUser | None, + environments: Sequence[Environment] | None, + value_converters=value_converters, + allow_aggregate_filters=False, +) -> Sequence[QueryToken | str]: """ Accepts a collection of SearchFilter objects and converts their values into a specific format, based on converters specified in `value_converters`. @@ -389,7 +400,12 @@ def convert_search_filter( @overload def convert_search_filter(search_filter: QueryOp, organization: Organization) -> QueryOp: ... - def convert_search_filter(search_filter: QueryToken, organization: Organization) -> QueryToken: + @overload + def convert_search_filter(search_filter: str, organization: Organization) -> str: ... + + def convert_search_filter( + search_filter: QueryToken | str, organization: Organization + ) -> QueryToken | str: if isinstance(search_filter, ParenExpression): return search_filter._replace( children=[ @@ -421,8 +437,8 @@ def convert_search_filter(search_filter: QueryToken, organization: Organization) return search_filter def expand_substatus_query_values( - search_filters: Sequence[QueryToken], org: Organization - ) -> Sequence[QueryToken]: + search_filters: Sequence[QueryToken | str], org: Organization + ) -> Sequence[QueryToken | str]: first_status_incl = None first_status_excl = None includes_status_filter = False diff --git a/src/sentry/search/events/builder/errors.py b/src/sentry/search/events/builder/errors.py index 86b6070bcf2008..b48c88ea857aff 100644 --- a/src/sentry/search/events/builder/errors.py +++ b/src/sentry/search/events/builder/errors.py @@ -18,6 +18,7 @@ ) from sentry.issues.issue_search import convert_query_values, convert_status_value +from sentry.search.events.builder.base import BaseQueryBuilder from sentry.search.events.builder.discover import ( DiscoverQueryBuilder, TimeseriesQueryBuilder, @@ -30,7 +31,7 @@ value_converters = {"status": convert_status_value} -class ErrorsQueryBuilderMixin: +class ErrorsQueryBuilderMixin(BaseQueryBuilder): def __init__(self, *args, **kwargs): self.match = None self.entities = set() diff --git a/src/sentry/search/events/datasets/base.py b/src/sentry/search/events/datasets/base.py index 4bd0d3a8186eea..da67bbb9937c5a 100644 --- a/src/sentry/search/events/datasets/base.py +++ b/src/sentry/search/events/datasets/base.py @@ -22,6 +22,7 @@ class DatasetConfig(abc.ABC): missing_function_error: ClassVar[type[Exception]] = InvalidSearchQuery optimize_wildcard_searches = False subscriptables_with_index: set[str] = set() + use_entity_prefix_for_fields: bool = False def __init__(self, builder: BaseQueryBuilder): pass From 762a7be6db7b8b9376cb0d0394b3e07fa9798cd3 Mon Sep 17 00:00:00 2001 From: Gabe Villalobos Date: Tue, 2 Jun 2026 11:59:29 -0700 Subject: [PATCH 24/46] ref(cell): Cleans up import loops with apigateways (#116658) --- src/sentry/api/base.py | 6 ++---- src/sentry/hybridcloud/apigateway/__init__.py | 3 --- src/sentry/hybridcloud/apigateway/middleware.py | 2 +- src/sentry/hybridcloud/apigateway_async/__init__.py | 3 --- src/sentry/hybridcloud/apigateway_async/middleware.py | 2 +- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index eae75ba36a2ad7..2f2fd96663f613 100644 --- a/src/sentry/api/base.py +++ b/src/sentry/api/base.py @@ -6,7 +6,7 @@ import time from collections.abc import Callable, Iterable, Mapping from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, TypedDict +from typing import Any, TypedDict from urllib.parse import quote as urlquote import sentry_sdk @@ -31,6 +31,7 @@ from sentry.apidocs.hooks import HTTP_METHOD_NAME from sentry.auth import access from sentry.auth.staff import has_staff_option +from sentry.hybridcloud.apigateway.cell_request_resolvers import CellRequestResolver from sentry.middleware import is_frontend_request from sentry.organizations.absolute_url import generate_organization_url from sentry.ratelimits.config import DEFAULT_RATE_LIMIT_CONFIG, RateLimitConfig @@ -68,9 +69,6 @@ SuperuserPermission, ) -if TYPE_CHECKING: - from sentry.hybridcloud.apigateway.cell_request_resolvers import CellRequestResolver - __all__ = [ "Endpoint", "StatsMixin", diff --git a/src/sentry/hybridcloud/apigateway/__init__.py b/src/sentry/hybridcloud/apigateway/__init__.py index e4b8561f6bee7d..e69de29bb2d1d6 100644 --- a/src/sentry/hybridcloud/apigateway/__init__.py +++ b/src/sentry/hybridcloud/apigateway/__init__.py @@ -1,3 +0,0 @@ -from .apigateway import proxy_request_if_needed - -__all__ = ("proxy_request_if_needed",) diff --git a/src/sentry/hybridcloud/apigateway/middleware.py b/src/sentry/hybridcloud/apigateway/middleware.py index 4c67467aac8302..d7e550b4c01423 100644 --- a/src/sentry/hybridcloud/apigateway/middleware.py +++ b/src/sentry/hybridcloud/apigateway/middleware.py @@ -6,7 +6,7 @@ from django.http.response import HttpResponseBase from rest_framework.request import Request -from sentry.hybridcloud.apigateway import proxy_request_if_needed +from .apigateway import proxy_request_if_needed class ApiGatewayMiddleware: diff --git a/src/sentry/hybridcloud/apigateway_async/__init__.py b/src/sentry/hybridcloud/apigateway_async/__init__.py index e4b8561f6bee7d..e69de29bb2d1d6 100644 --- a/src/sentry/hybridcloud/apigateway_async/__init__.py +++ b/src/sentry/hybridcloud/apigateway_async/__init__.py @@ -1,3 +0,0 @@ -from .apigateway import proxy_request_if_needed - -__all__ = ("proxy_request_if_needed",) diff --git a/src/sentry/hybridcloud/apigateway_async/middleware.py b/src/sentry/hybridcloud/apigateway_async/middleware.py index 963b3530a86013..a5ac0c6f07d446 100644 --- a/src/sentry/hybridcloud/apigateway_async/middleware.py +++ b/src/sentry/hybridcloud/apigateway_async/middleware.py @@ -7,7 +7,7 @@ from django.http.response import HttpResponseBase from rest_framework.request import Request -from . import proxy_request_if_needed +from .apigateway import proxy_request_if_needed class ApiGatewayMiddleware: From 4e8716f08e28ed94e783a0843b85b23f8043d624 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 2 Jun 2026 19:06:22 +0000 Subject: [PATCH 25/46] Revert "ref(webhooks): Hide PLUGIN action type from available actions endpoint (#116458)" This reverts commit 6320f1ba8c493df89dc2aa27d3e3bdd41039b9b5. Co-authored-by: Christinarlong <60594860+Christinarlong@users.noreply.github.com> --- .../endpoints/organization_available_action_index.py | 11 ++++++----- .../test_organization_available_action_index.py | 10 ++++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/sentry/workflow_engine/endpoints/organization_available_action_index.py b/src/sentry/workflow_engine/endpoints/organization_available_action_index.py index 15f2d2b8d310e3..ecdaf0dc383319 100644 --- a/src/sentry/workflow_engine/endpoints/organization_available_action_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_available_action_index.py @@ -162,10 +162,7 @@ def get( ) ) - elif action_type == Action.Type.PLUGIN: - continue - - # add all other action types (EMAIL, etc.) + # add all other action types (EMAIL, PLUGIN, etc.) else: actions.append( serialize( @@ -176,7 +173,11 @@ def get( actions.sort( key=lambda x: ( x["handlerGroup"], - (0 if x["type"] in [Action.Type.EMAIL, Action.Type.WEBHOOK] else 1), + ( + 0 + if x["type"] in [Action.Type.EMAIL, Action.Type.PLUGIN, Action.Type.WEBHOOK] + else 1 + ), x["type"], (x["sentryApp"].get("name", "") if x.get("sentryApp") else ""), ) diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py index f086c0386993df..71c2c579b23cd9 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_available_action_index.py @@ -477,7 +477,7 @@ class PluginActionHandler(ActionHandler): self.organization.slug, status_code=200, ) - assert len(response.data) == 7 + assert len(response.data) == 8 assert response.data == [ # notification actions, sorted alphabetically with email first { @@ -495,7 +495,13 @@ class PluginActionHandler(ActionHandler): {"id": str(self.slack_integration.id), "name": self.slack_integration.name} ], }, - # other actions — PLUGIN is registered but hidden from available actions + # other actions, non sentry app actions first then sentry apps sorted alphabetically by name + { + "type": Action.Type.PLUGIN, + "handlerGroup": ActionHandler.Group.OTHER.value, + "configSchema": {}, + "dataSchema": {}, + }, # webhook action should include sentry apps without components { "type": Action.Type.WEBHOOK, From 7559e1ab7a8a3c4cad291425aba4e088546f17c9 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 2 Jun 2026 14:09:18 -0500 Subject: [PATCH 26/46] ref(detectors): Remove unnessary parameters and interfaces (#116709) Removes `is_creation_allowed_for_organization` and `is_creation_allowed_for_project`. Adds a new `is_creation_allowed` method which accepts no arguments. This removes the unnecessary dependency on organization and project model instances. --- src/sentry/issue_detection/README.md | 8 +++---- src/sentry/issue_detection/base.py | 16 ++----------- .../detectors/consecutive_db_detector.py | 9 ++----- .../detectors/consecutive_http_detector.py | 9 ++----- .../detectors/http_overhead_detector.py | 10 ++------ .../detectors/io_main_thread_detector.py | 15 +++--------- .../detectors/large_http_payload_detector.py | 10 ++------ .../detectors/mn_plus_one_db_span_detector.py | 10 ++------ .../n_plus_one_api_calls_detector.py | 9 ++----- .../detectors/n_plus_one_db_span_detector.py | 10 ++------ .../detectors/query_injection_detector.py | 10 ++------ .../render_blocking_asset_span_detector.py | 10 ++------ .../detectors/slow_db_query_detector.py | 7 +----- .../detectors/sql_injection_detector.py | 9 ++----- .../detectors/uncompressed_asset_detector.py | 9 ++----- .../issue_detection/performance_detection.py | 16 +++---------- .../test_consecutive_db_detector.py | 4 ++-- .../test_consecutive_http_detector.py | 4 ++-- .../test_db_main_thread_detector.py | 4 ++-- .../test_file_io_on_main_thread_detector.py | 4 ++-- .../test_large_http_payload_detector.py | 24 ++++++------------- .../test_m_n_plus_one_db_detector.py | 4 ++-- .../test_n_plus_one_db_span_detector.py | 4 ++-- .../test_performance_detection.py | 22 ++--------------- .../test_render_blocking_asset_detector.py | 4 ++-- .../test_slow_db_span_detector.py | 4 ++-- .../test_uncompressed_asset_detector.py | 4 ++-- 27 files changed, 61 insertions(+), 188 deletions(-) diff --git a/src/sentry/issue_detection/README.md b/src/sentry/issue_detection/README.md index fd6c6328f43aaa..343eca406e4477 100644 --- a/src/sentry/issue_detection/README.md +++ b/src/sentry/issue_detection/README.md @@ -18,9 +18,8 @@ Performance Issues are built on top of the [Issue Platform](https://develop.sent - If the system option is a boolean, it will only run the detector if set to `True`. - If the system option is a number (0.0 < `x` < 1.0), it will run the detector `100 * x`% of the time. - After this check, the detectors are run on the event, see `run_detector_on_data()` in [performance_detection.py](./performance_detection.py) - - After recording metrics about the results, two checks are run, dropping the `PerformanceProblem`s if either return `False`: - - `PerformanceDetector.is_creation_allowed_for_organization()` - Pre-GA, check a feature flag; post-GA, just return `True` - - `PerformanceDetector.is_creation_allowed_for_project()` - Usually checking project's detector settings + - After recording metrics about the results, a check is run, dropping the `PerformanceProblem`s if it returns `False`: + - `PerformanceDetector.is_creation_allowed()` - Usually checking project's detector settings - We store the list of `PerformanceProblem`s from `_detect_performance_problems` on `job["performance_problems"]` - Then we run `_send_occurrence_to_platform` which reads `job["performance_problems"]` - It will map each `PerformanceProblem` into an `IssueOccurrence` @@ -59,8 +58,7 @@ There are quite a few places which need to be updated when adding a new performa - [ ] Setup for the `PerformanceDetector` subclass - [ ] Update the `type` and `settings_key` attributes with the new `DetectorType` - [ ] (Optional) Implement `is_event_eligible()` to allow early exits. - - [ ] Implement `is_creation_allowed_for_organization()` to check a feature flag. - - [ ] Implement `is_creation_allowed_for_project()` to check a creation flag. + - [ ] Implement `is_creation_allowed()` to check a creation flag. - [ ] Write some business logic! - [ ] Implement `visit_span()` and `on_complete()`, adding any identified `PerformanceProblem`s to `self.stored_problems` as you go. - [ ] Leverage the [Writing Detectors docs](https://develop.sentry.dev/backend/issue-platform/writing-detectors/) which can help guide your detector's design. diff --git a/src/sentry/issue_detection/base.py b/src/sentry/issue_detection/base.py index 0ab40dc4f11165..d4f7c564e18833 100644 --- a/src/sentry/issue_detection/base.py +++ b/src/sentry/issue_detection/base.py @@ -9,7 +9,6 @@ from sentry import options from sentry.issue_detection.detectors.utils import get_span_duration from sentry.issue_detection.performance_problem import PerformanceProblem -from sentry.models.organization import Organization from sentry.models.project import Project from .types import Span @@ -62,13 +61,11 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: self.settings = settings self._event = event self.stored_problems: dict[str, PerformanceProblem] = {} - self.organization = organization self.detector_id = detector_id def find_span_prefix(self, settings: dict[str, Any], span_op: str) -> str | bool: @@ -128,19 +125,10 @@ def is_detection_allowed_for_system(cls) -> bool: except options.UnknownOption: return False - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: + def is_creation_allowed(self) -> bool: """ After running the detector, this method determines whether the found problems should be - passed to the issue platform for a given organization. - - See `_detect_performance_problems` in `performance_detection.py` for more context. - """ - return False - - def is_creation_allowed_for_project(self, project: Project) -> bool: - """ - After running the detector, this method determines whether the found problems should be - passed to the issue platform for a given project. + passed to the issue platform. See `_detect_performance_problems` in `performance_detection.py` for more context. """ diff --git a/src/sentry/issue_detection/detectors/consecutive_db_detector.py b/src/sentry/issue_detection/detectors/consecutive_db_detector.py index bfbb275d6de4f0..1ebead4f73275c 100644 --- a/src/sentry/issue_detection/detectors/consecutive_db_detector.py +++ b/src/sentry/issue_detection/detectors/consecutive_db_detector.py @@ -18,7 +18,6 @@ ) from sentry.issues.grouptype import PerformanceConsecutiveDBQueriesGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization from sentry.models.project import Project from sentry.utils.event_frames import get_sdk_name @@ -62,10 +61,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.consecutive_db_spans: list[Span] = [] self.independent_db_spans: list[Span] = [] @@ -257,10 +255,7 @@ def _fingerprint(self) -> str: def on_complete(self) -> None: self._validate_and_store_performance_problem() - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] @classmethod diff --git a/src/sentry/issue_detection/detectors/consecutive_http_detector.py b/src/sentry/issue_detection/detectors/consecutive_http_detector.py index f8d1a2fcb39709..6dfcfd061d969c 100644 --- a/src/sentry/issue_detection/detectors/consecutive_http_detector.py +++ b/src/sentry/issue_detection/detectors/consecutive_http_detector.py @@ -18,7 +18,6 @@ ) from sentry.issues.grouptype import PerformanceConsecutiveHTTPQueriesGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization from sentry.models.project import Project from sentry.utils.event import is_event_from_browser_javascript_sdk from sentry.utils.safe import get_path @@ -35,10 +34,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.consecutive_http_spans: list[Span] = [] self.lcp = None @@ -208,8 +206,5 @@ def _fingerprint(self) -> str: def on_complete(self) -> None: self._validate_and_store_performance_problem() - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] diff --git a/src/sentry/issue_detection/detectors/http_overhead_detector.py b/src/sentry/issue_detection/detectors/http_overhead_detector.py index 409a7581d51a95..03b3e7cb1d93ef 100644 --- a/src/sentry/issue_detection/detectors/http_overhead_detector.py +++ b/src/sentry/issue_detection/detectors/http_overhead_detector.py @@ -15,8 +15,6 @@ ) from sentry.issues.grouptype import PerformanceHTTPOverheadGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization -from sentry.models.project import Project from ..performance_problem import PerformanceProblem from ..types import Span @@ -55,10 +53,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.location_to_indicators: dict[str, list[list[ProblemIndicator]]] = defaultdict(list) @@ -194,8 +191,5 @@ def on_complete(self) -> None: for location in self.location_to_indicators: self._store_performance_problem(location) - def is_creation_allowed_for_organization(self, organization: Organization | None) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] diff --git a/src/sentry/issue_detection/detectors/io_main_thread_detector.py b/src/sentry/issue_detection/detectors/io_main_thread_detector.py index 7dd55f4dd21e2b..b224d22c494eab 100644 --- a/src/sentry/issue_detection/detectors/io_main_thread_detector.py +++ b/src/sentry/issue_detection/detectors/io_main_thread_detector.py @@ -15,7 +15,6 @@ from sentry.issues.issue_occurrence import IssueEvidence from sentry.lang.java.proguard import open_proguard_mapper from sentry.models.debugfile import ProjectDebugFile -from sentry.models.organization import Organization from sentry.models.project import Project from ..base import DetectorType, PerformanceDetector @@ -42,10 +41,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.mapper: ProguardMapper | None = None self.parent_to_blocked_span: dict[str, list[Span]] = defaultdict(list) @@ -110,7 +108,7 @@ def on_complete(self) -> None: ], ) - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] @@ -201,9 +199,6 @@ def _is_io_on_main_thread(self, span: Span) -> bool: # doing is True since the value can be any type return data.get("blocked_main_thread", False) is True - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True - class DBMainThreadDetector(BaseIOMainThreadDetector): """ @@ -219,10 +214,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.mapper = None self.parent_to_blocked_span = defaultdict(list) @@ -244,6 +238,3 @@ def _is_io_on_main_thread(self, span: Span) -> bool: return False # doing is True since the value can be any type return data.get("blocked_main_thread", False) is True - - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True diff --git a/src/sentry/issue_detection/detectors/large_http_payload_detector.py b/src/sentry/issue_detection/detectors/large_http_payload_detector.py index 370b0a9d71691b..79fd18d7caeec2 100644 --- a/src/sentry/issue_detection/detectors/large_http_payload_detector.py +++ b/src/sentry/issue_detection/detectors/large_http_payload_detector.py @@ -6,8 +6,6 @@ from sentry.issues.grouptype import PerformanceLargeHTTPPayloadGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization -from sentry.models.project import Project from ..base import DetectorType, PerformanceDetector from ..detectors.utils import ( @@ -32,10 +30,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.consecutive_http_spans: list[Span] = [] self.filtered_paths = [ @@ -141,8 +138,5 @@ def _fingerprint(self, span: Span) -> str: hashed_url_paths = fingerprint_http_spans([span]) return f"1-{PerformanceLargeHTTPPayloadGroupType.type_id}-{hashed_url_paths}" - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] diff --git a/src/sentry/issue_detection/detectors/mn_plus_one_db_span_detector.py b/src/sentry/issue_detection/detectors/mn_plus_one_db_span_detector.py index 8daaed6251de8d..ea2fc8a7679c72 100644 --- a/src/sentry/issue_detection/detectors/mn_plus_one_db_span_detector.py +++ b/src/sentry/issue_detection/detectors/mn_plus_one_db_span_detector.py @@ -19,8 +19,6 @@ PerformanceNPlusOneGroupType, ) from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization -from sentry.models.project import Project from sentry.utils import metrics @@ -396,10 +394,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.state: MNPlusOneState = SearchingForMNPlusOne( settings=self.settings, @@ -407,10 +404,7 @@ def __init__( parent_map={}, ) - def is_creation_allowed_for_organization(self, organization: Organization | None) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] def visit_span(self, span: Span) -> None: diff --git a/src/sentry/issue_detection/detectors/n_plus_one_api_calls_detector.py b/src/sentry/issue_detection/detectors/n_plus_one_api_calls_detector.py index cdf5923a4c62cd..f3e433ee86a463 100644 --- a/src/sentry/issue_detection/detectors/n_plus_one_api_calls_detector.py +++ b/src/sentry/issue_detection/detectors/n_plus_one_api_calls_detector.py @@ -22,7 +22,6 @@ from sentry.issue_detection.types import Span from sentry.issues.grouptype import PerformanceNPlusOneAPICallsGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization from sentry.models.project import Project from sentry.utils.safe import get_path @@ -46,10 +45,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) # TODO: Only store the span IDs and timestamps instead of entire span objects self.spans: list[Span] = [] @@ -74,10 +72,7 @@ def visit_span(self, span: Span) -> None: self._maybe_store_problem() self.spans = [span] - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] @classmethod diff --git a/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py b/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py index 0ce29a42460738..79e16b7d9adc02 100644 --- a/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py +++ b/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py @@ -13,8 +13,6 @@ from sentry.issue_detection.types import Span from sentry.issues.grouptype import PerformanceNPlusOneGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization -from sentry.models.project import Project from sentry.utils import metrics from sentry.utils.safe import get_path @@ -60,10 +58,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.potential_parents = {} self.previous_span: Span | None = None @@ -73,10 +70,7 @@ def __init__( if root_span: self.potential_parents[root_span.get("span_id")] = root_span - def is_creation_allowed_for_organization(self, organization: Organization | None) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project | None) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] def visit_span(self, span: Span) -> None: diff --git a/src/sentry/issue_detection/detectors/query_injection_detector.py b/src/sentry/issue_detection/detectors/query_injection_detector.py index 72ba98f91176ca..d7481fc793c847 100644 --- a/src/sentry/issue_detection/detectors/query_injection_detector.py +++ b/src/sentry/issue_detection/detectors/query_injection_detector.py @@ -9,8 +9,6 @@ from sentry.issue_detection.types import Span from sentry.issues.grouptype import QueryInjectionVulnerabilityGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization -from sentry.models.project import Project from sentry.utils import json MAX_EVIDENCE_VALUE_LENGTH = 10_000 @@ -26,10 +24,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.stored_problems = {} self.potential_unsafe_inputs: list[tuple[str, dict[str, Any]]] = [] @@ -121,10 +118,7 @@ def visit_span(self, span: Span) -> None: ], ) - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project | None) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] def _is_span_eligible(self, span: Span) -> bool: diff --git a/src/sentry/issue_detection/detectors/render_blocking_asset_span_detector.py b/src/sentry/issue_detection/detectors/render_blocking_asset_span_detector.py index d6a10ad1906361..5eab3fba829251 100644 --- a/src/sentry/issue_detection/detectors/render_blocking_asset_span_detector.py +++ b/src/sentry/issue_detection/detectors/render_blocking_asset_span_detector.py @@ -6,8 +6,6 @@ from sentry.issues.grouptype import PerformanceRenderBlockingAssetSpanGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization -from sentry.models.project import Project from ..base import DetectorType, PerformanceDetector from ..detectors.utils import ( @@ -30,10 +28,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.transaction_start = timedelta(seconds=self.event().get("start_timestamp", 0)) self.fcp = None @@ -52,10 +49,7 @@ def __init__( self.fcp = fcp self.fcp_value = fcp_value - def is_creation_allowed_for_organization(self, organization: Organization | None) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] def visit_span(self, span: Span) -> None: diff --git a/src/sentry/issue_detection/detectors/slow_db_query_detector.py b/src/sentry/issue_detection/detectors/slow_db_query_detector.py index 0ef6a509f1cf16..64d571dc963865 100644 --- a/src/sentry/issue_detection/detectors/slow_db_query_detector.py +++ b/src/sentry/issue_detection/detectors/slow_db_query_detector.py @@ -6,8 +6,6 @@ from sentry.issues.grouptype import PerformanceSlowDBQueryGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization -from sentry.models.project import Project from ..base import DetectorType, PerformanceDetector from ..detectors.utils import ( @@ -102,10 +100,7 @@ def visit_span(self, span: Span) -> None: ], ) - def is_creation_allowed_for_organization(self, organization: Organization | None) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project | None) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] def _is_span_eligible(self, span: Span) -> bool: diff --git a/src/sentry/issue_detection/detectors/sql_injection_detector.py b/src/sentry/issue_detection/detectors/sql_injection_detector.py index ff185066e68d86..d89855a0fcf7dd 100644 --- a/src/sentry/issue_detection/detectors/sql_injection_detector.py +++ b/src/sentry/issue_detection/detectors/sql_injection_detector.py @@ -11,7 +11,6 @@ from sentry.issue_detection.types import Span from sentry.issues.grouptype import QueryInjectionVulnerabilityGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization from sentry.models.project import Project MAX_EVIDENCE_VALUE_LENGTH = 10_000 @@ -80,10 +79,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.stored_problems = {} self.request_parameters: list[Sequence[Any]] = [] @@ -213,10 +211,7 @@ def visit_span(self, span: Span) -> None: ], ) - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project | None) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] def _is_span_eligible(self, span: Span) -> bool: diff --git a/src/sentry/issue_detection/detectors/uncompressed_asset_detector.py b/src/sentry/issue_detection/detectors/uncompressed_asset_detector.py index 0b655dc20f1361..a893f3eb770358 100644 --- a/src/sentry/issue_detection/detectors/uncompressed_asset_detector.py +++ b/src/sentry/issue_detection/detectors/uncompressed_asset_detector.py @@ -6,7 +6,6 @@ from sentry.issues.grouptype import PerformanceUncompressedAssetsGroupType from sentry.issues.issue_occurrence import IssueEvidence -from sentry.models.organization import Organization from sentry.models.project import Project from ..base import DetectorType, PerformanceDetector @@ -37,10 +36,9 @@ def __init__( self, settings: dict[str, Any], event: dict[str, Any], - organization: Organization | None = None, detector_id: int | None = None, ) -> None: - super().__init__(settings, event, organization, detector_id) + super().__init__(settings, event, detector_id) self.any_compression = False @@ -144,10 +142,7 @@ def _fingerprint(self, span: Span) -> str: resource_span = fingerprint_resource_span(span) return f"1-{PerformanceUncompressedAssetsGroupType.type_id}-{resource_span}" - def is_creation_allowed_for_organization(self, organization: Organization) -> bool: - return True - - def is_creation_allowed_for_project(self, project: Project) -> bool: + def is_creation_allowed(self) -> bool: return self.settings["detection_enabled"] @classmethod diff --git a/src/sentry/issue_detection/performance_detection.py b/src/sentry/issue_detection/performance_detection.py index 48a1e1bf6767bd..fffc7f36abd020 100644 --- a/src/sentry/issue_detection/performance_detection.py +++ b/src/sentry/issue_detection/performance_detection.py @@ -750,7 +750,7 @@ def _detect_performance_problems( with sentry_sdk.start_span(op="initialize", name="PerformanceDetector"): detectors: list[PerformanceDetector] = [ - detector_class(detection_settings[detector_class.settings_key], data, organization) + detector_class(detection_settings[detector_class.settings_key], data) for detector_class in DETECTOR_CLASSES if detector_class.is_detection_allowed_for_system() ] @@ -776,12 +776,7 @@ def _detect_performance_problems( problems: list[PerformanceProblem] = [] with sentry_sdk.start_span(op="performance_detection", name="is_creation_allowed"): for detector in detectors: - if all( - [ - detector.is_creation_allowed_for_organization(organization), - detector.is_creation_allowed_for_project(project), - ] - ): + if detector.is_creation_allowed(): problems.extend(detector.stored_problems.values()) else: continue @@ -964,12 +959,7 @@ def report_metrics_for_detectors( op_tags = { "is_standalone_spans": standalone, - "is_creation_allowed": all( - [ - detector.is_creation_allowed_for_organization(organization), - detector.is_creation_allowed_for_project(project), - ] - ), + "is_creation_allowed": detector.is_creation_allowed(), } for problem in detected_problems.values(): op = problem.op diff --git a/tests/sentry/issue_detection/test_consecutive_db_detector.py b/tests/sentry/issue_detection/test_consecutive_db_detector.py index 89b99e3f1e5bfd..15f47ff376c5f7 100644 --- a/tests/sentry/issue_detection/test_consecutive_db_detector.py +++ b/tests/sentry/issue_detection/test_consecutive_db_detector.py @@ -253,7 +253,7 @@ def test_respects_project_option(self) -> None: settings[ConsecutiveDBSpanDetector.settings_key], event ) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -266,7 +266,7 @@ def test_respects_project_option(self) -> None: settings[ConsecutiveDBSpanDetector.settings_key], event ) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() def test_detects_consecutive_db_does_not_detect_php(self) -> None: event = self.create_issue_event() diff --git a/tests/sentry/issue_detection/test_consecutive_http_detector.py b/tests/sentry/issue_detection/test_consecutive_http_detector.py index 1e629bcd8f33ee..160f6119b90a06 100644 --- a/tests/sentry/issue_detection/test_consecutive_http_detector.py +++ b/tests/sentry/issue_detection/test_consecutive_http_detector.py @@ -372,7 +372,7 @@ def test_respects_project_option(self) -> None: settings[ConsecutiveHTTPSpanDetector.settings_key], event ) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -385,7 +385,7 @@ def test_respects_project_option(self) -> None: settings[ConsecutiveHTTPSpanDetector.settings_key], event ) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() def test_ignores_non_http_operations(self) -> None: span_duration = 2000 diff --git a/tests/sentry/issue_detection/test_db_main_thread_detector.py b/tests/sentry/issue_detection/test_db_main_thread_detector.py index a6b16e4039c509..63ae5d718edca3 100644 --- a/tests/sentry/issue_detection/test_db_main_thread_detector.py +++ b/tests/sentry/issue_detection/test_db_main_thread_detector.py @@ -57,7 +57,7 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = DBMainThreadDetector(settings[DBMainThreadDetector.settings_key], event) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -68,7 +68,7 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = DBMainThreadDetector(settings[DBMainThreadDetector.settings_key], event) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() def test_does_not_detect_db_main_thread(self) -> None: event = get_event("db-on-main-thread/db-on-main-thread") diff --git a/tests/sentry/issue_detection/test_file_io_on_main_thread_detector.py b/tests/sentry/issue_detection/test_file_io_on_main_thread_detector.py index b6b4db7321dbc8..b48b85ab351940 100644 --- a/tests/sentry/issue_detection/test_file_io_on_main_thread_detector.py +++ b/tests/sentry/issue_detection/test_file_io_on_main_thread_detector.py @@ -60,7 +60,7 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = FileIOMainThreadDetector(settings[FileIOMainThreadDetector.settings_key], event) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -71,7 +71,7 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = FileIOMainThreadDetector(settings[FileIOMainThreadDetector.settings_key], event) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() def test_detects_file_io_main_thread(self) -> None: event = get_event("file-io-on-main-thread/file-io-on-main-thread") diff --git a/tests/sentry/issue_detection/test_large_http_payload_detector.py b/tests/sentry/issue_detection/test_large_http_payload_detector.py index 6b73ecfc8f8098..d001dfffc81548 100644 --- a/tests/sentry/issue_detection/test_large_http_payload_detector.py +++ b/tests/sentry/issue_detection/test_large_http_payload_detector.py @@ -83,11 +83,9 @@ def test_respects_project_option(self) -> None: event["project_id"] = project.id settings = get_detection_settings(project) - detector = LargeHTTPPayloadDetector( - settings[LargeHTTPPayloadDetector.settings_key], event, self.organization - ) + detector = LargeHTTPPayloadDetector(settings[LargeHTTPPayloadDetector.settings_key], event) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -96,11 +94,9 @@ def test_respects_project_option(self) -> None: ) settings = get_detection_settings(project) - detector = LargeHTTPPayloadDetector( - settings[LargeHTTPPayloadDetector.settings_key], event, self.organization - ) + detector = LargeHTTPPayloadDetector(settings[LargeHTTPPayloadDetector.settings_key], event) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() def test_does_not_issue_if_url_is_not_an_http_span(self) -> None: spans = [ @@ -330,9 +326,7 @@ def test_does_not_trigger_detection_for_filtered_paths(self) -> None: ] event = create_event(spans) - detector = LargeHTTPPayloadDetector( - settings[LargeHTTPPayloadDetector.settings_key], event, self.organization - ) + detector = LargeHTTPPayloadDetector(settings[LargeHTTPPayloadDetector.settings_key], event) run_detector_on_data(detector, event) assert len(detector.stored_problems) == 0 @@ -359,9 +353,7 @@ def test_does_not_trigger_detection_for_filtered_paths_without_trailing_slash(se ] event = create_event(spans) - detector = LargeHTTPPayloadDetector( - settings[LargeHTTPPayloadDetector.settings_key], event, self.organization - ) + detector = LargeHTTPPayloadDetector(settings[LargeHTTPPayloadDetector.settings_key], event) run_detector_on_data(detector, event) assert len(detector.stored_problems) == 1 @@ -380,8 +372,6 @@ def test_does_not_trigger_detection_for_filtered_paths_without_trailing_slash(se ] event = create_event(spans) - detector = LargeHTTPPayloadDetector( - settings[LargeHTTPPayloadDetector.settings_key], event, self.organization - ) + detector = LargeHTTPPayloadDetector(settings[LargeHTTPPayloadDetector.settings_key], event) run_detector_on_data(detector, event) assert len(detector.stored_problems) == 0 diff --git a/tests/sentry/issue_detection/test_m_n_plus_one_db_detector.py b/tests/sentry/issue_detection/test_m_n_plus_one_db_detector.py index b11ccc5578b9b9..4e6a38d4765fb4 100644 --- a/tests/sentry/issue_detection/test_m_n_plus_one_db_detector.py +++ b/tests/sentry/issue_detection/test_m_n_plus_one_db_detector.py @@ -222,7 +222,7 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = self.detector(settings[self.detector.settings_key], event) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -233,7 +233,7 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = self.detector(settings[self.detector.settings_key], event) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() def test_respects_n_plus_one_db_duration_threshold(self) -> None: project = self.create_project() diff --git a/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py b/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py index 29e19628631fa2..a48770bf1158a7 100644 --- a/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py +++ b/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py @@ -407,7 +407,7 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = NPlusOneDBSpanDetector(settings[NPlusOneDBSpanDetector.settings_key], event) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -418,4 +418,4 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = NPlusOneDBSpanDetector(settings[NPlusOneDBSpanDetector.settings_key], event) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() diff --git a/tests/sentry/issue_detection/test_performance_detection.py b/tests/sentry/issue_detection/test_performance_detection.py index 72a12fde98e615..01743918d8e3d7 100644 --- a/tests/sentry/issue_detection/test_performance_detection.py +++ b/tests/sentry/issue_detection/test_performance_detection.py @@ -382,29 +382,11 @@ def test_system_option_used_when_project_option_is_default(self) -> None: assert_n_plus_one_db_problem(perf_problems) @override_options({"performance.issues.n_plus_one_db.problem-creation": 1.0}) - def test_respects_organization_creation_permissions(self) -> None: + def test_respects_creation_permissions(self) -> None: n_plus_one_event = get_event("n-plus-one-db/n-plus-one-in-django-index-view") sdk_span_mock = Mock() - with patch.object( - NPlusOneDBSpanDetector, "is_creation_allowed_for_organization", return_value=False - ): - perf_problems = _detect_performance_problems( - n_plus_one_event, sdk_span_mock, self.project - ) - assert perf_problems == [] - - perf_problems = _detect_performance_problems(n_plus_one_event, sdk_span_mock, self.project) - assert_n_plus_one_db_problem(perf_problems) - - @override_options({"performance.issues.n_plus_one_db.problem-creation": 1.0}) - def test_respects_project_creation_permissions(self) -> None: - n_plus_one_event = get_event("n-plus-one-db/n-plus-one-in-django-index-view") - sdk_span_mock = Mock() - - with patch.object( - NPlusOneDBSpanDetector, "is_creation_allowed_for_project", return_value=False - ): + with patch.object(NPlusOneDBSpanDetector, "is_creation_allowed", return_value=False): perf_problems = _detect_performance_problems( n_plus_one_event, sdk_span_mock, self.project ) diff --git a/tests/sentry/issue_detection/test_render_blocking_asset_detector.py b/tests/sentry/issue_detection/test_render_blocking_asset_detector.py index 357f7a6076c2cd..bb55f536ecf68e 100644 --- a/tests/sentry/issue_detection/test_render_blocking_asset_detector.py +++ b/tests/sentry/issue_detection/test_render_blocking_asset_detector.py @@ -100,7 +100,7 @@ def test_respects_project_option(self) -> None: settings[RenderBlockingAssetSpanDetector.settings_key], event ) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -113,7 +113,7 @@ def test_respects_project_option(self) -> None: settings[RenderBlockingAssetSpanDetector.settings_key], event ) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() def test_does_not_detect_if_resource_overlaps_fcp(self) -> None: event = _valid_render_blocking_asset_event("https://example.com/a.js") diff --git a/tests/sentry/issue_detection/test_slow_db_span_detector.py b/tests/sentry/issue_detection/test_slow_db_span_detector.py index a4dff766dbe3d0..996ff5e9a28f7f 100644 --- a/tests/sentry/issue_detection/test_slow_db_span_detector.py +++ b/tests/sentry/issue_detection/test_slow_db_span_detector.py @@ -143,7 +143,7 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = SlowDBQueryDetector(settings[SlowDBQueryDetector.settings_key], slow_span_event) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -154,4 +154,4 @@ def test_respects_project_option(self) -> None: settings = get_detection_settings(project) detector = SlowDBQueryDetector(settings[SlowDBQueryDetector.settings_key], slow_span_event) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() diff --git a/tests/sentry/issue_detection/test_uncompressed_asset_detector.py b/tests/sentry/issue_detection/test_uncompressed_asset_detector.py index a63023e0b3685e..f8a9d4ca94985c 100644 --- a/tests/sentry/issue_detection/test_uncompressed_asset_detector.py +++ b/tests/sentry/issue_detection/test_uncompressed_asset_detector.py @@ -430,7 +430,7 @@ def test_respects_project_option(self) -> None: settings[UncompressedAssetSpanDetector.settings_key], event ) - assert detector.is_creation_allowed_for_project(project) + assert detector.is_creation_allowed() ProjectOption.objects.set_value( project=project, @@ -443,4 +443,4 @@ def test_respects_project_option(self) -> None: settings[UncompressedAssetSpanDetector.settings_key], event ) - assert not detector.is_creation_allowed_for_project(project) + assert not detector.is_creation_allowed() From 91845a300cda3fb44eedbb793cc260649a955863 Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 2 Jun 2026 14:37:41 -0500 Subject: [PATCH 27/46] ref(onboarding): Extract ScmIntegrationConnect from ScmConnect (#116581) ## TL;DR Splits `ScmConnect` into a reusable core component (`ScmIntegrationConnect`) and the onboarding chrome that wraps it. The core owns integration data fetching, platform detection pre-warming, the `scm_connect_step_viewed` analytic, and the provider-pills / repo-selector conditional. The `ScmConnect` wrapper keeps the connect-step heading, lock/revoke text, benefits grid, and Continue/Skip footer. No behavior change for onboarding; the extraction unblocks PR 4 in the stack which reuses just the connection mechanic in project creation. --- ## Stack - [PR 1](https://github.com/getsentry/sentry/pull/116434): Make decoupled SCM components flow-aware for analytics - [PR 2](https://github.com/getsentry/sentry/pull/116577): Scaffold SCM-first project creation as a single view - **PR 3 (this):** Extract `ScmIntegrationConnect` from `ScmConnect` - PR 4 (next): Wire `ScmIntegrationConnect` into the single-view scaffold ## Summary - New file `static/app/views/onboarding/components/scmIntegrationConnect.tsx` hosts `ScmIntegrationConnect`. Props: `analyticsFlow`, `selectedIntegration`, `selectedRepository`, `onIntegrationChange`, `onRepositoryChange`, `onClearDerivedState`. - `ScmConnect` now imports and renders ``. It still calls `useScmProviders()` to derive `effectiveIntegration` for the footer's analyticsParams and the "commit auto-detected integration on Continue" behavior. React Query dedupes with the core's fetch. - `MotionStack` moves with the core. `MotionFlex` and `MotionGrid` (lock text, info grid, footer) stay in the wrapper. `LayoutGroup` stays in the wrapper, wrapping both the core (as a child) and the wrapper's own motion components, so layout coordination is preserved. - `STEP_VIEWED_EVENT` moves into the core. Onboarding still fires `onboarding.scm_connect_step_viewed` on mount; project creation will fire `project_creation.scm_connect_step_viewed` once PR 4 wires the core directly. Refs VDY-74 ## Test plan - [x] `pnpm typecheck` clean - [x] `pnpm lint:js` clean on touched files - [x] `pnpm test-ci` passes on dependent specs (`onboarding.spec.tsx`, `scmPlatformFeatures.spec.tsx`, `scmProjectDetails.spec.tsx`, `scmRepoSelector.spec.tsx`) - [ ] Onboarding connect step still renders header, integration/repo block, lock text, info grid, and Continue/Skip footer - [ ] Switching between with-integration and without-integration states still works as before --- .../components/scmIntegrationConnect.tsx | 131 ++++++++++++++++++ static/app/views/onboarding/scmConnect.tsx | 92 ++---------- 2 files changed, 145 insertions(+), 78 deletions(-) create mode 100644 static/app/views/onboarding/components/scmIntegrationConnect.tsx diff --git a/static/app/views/onboarding/components/scmIntegrationConnect.tsx b/static/app/views/onboarding/components/scmIntegrationConnect.tsx new file mode 100644 index 00000000000000..dd1d4cab18badd --- /dev/null +++ b/static/app/views/onboarding/components/scmIntegrationConnect.tsx @@ -0,0 +1,131 @@ +import {useCallback, useEffect} from 'react'; +import {motion} from 'framer-motion'; + +import {Button} from '@sentry/scraps/button'; +import {Flex, Stack, type StackProps} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {t} from 'sentry/locale'; +import type {Integration, Repository} from 'sentry/types/integrations'; +import {trackAnalytics} from 'sentry/utils/analytics'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {SCM_STEP_CONTENT_WIDTH} from 'sentry/views/onboarding/consts'; + +import type {ScmAnalyticsFlow} from './scmAnalyticsFlow'; +import {ScmProviderPills} from './scmProviderPills'; +import {ScmRepoSelector} from './scmRepoSelector'; +import {useScmPlatformDetection} from './useScmPlatformDetection'; +import {useScmProviders} from './useScmProviders'; + +const STEP_VIEWED_EVENT = { + onboarding: 'onboarding.scm_connect_step_viewed', + 'project-creation': 'project_creation.scm_connect_step_viewed', +} as const; + +interface ScmIntegrationConnectProps { + analyticsFlow: ScmAnalyticsFlow; + // Fired once per user-driven repo change so callers can invalidate state + // derived from the repo (platform, features, created project). See + // ScmRepoSelector for why this is separate from onRepositoryChange. + onClearDerivedState: () => void; + onIntegrationChange: (integration: Integration | undefined) => void; + onRepositoryChange: (repo: Repository | undefined) => void; + selectedIntegration: Integration | undefined; + selectedRepository: Repository | undefined; + maxWidth?: StackProps['maxWidth']; +} + +/** + * Core integration-and-repo connection mechanic shared by the SCM connect step + * (`ScmConnect`) and the SCM-first project creation surface. Renders the + * provider install pills when no integration is connected, or the repo + * selector when one is. Owns integration data fetching, platform detection + * pre-warming, and the `scm_connect_step_viewed` analytic. + * + * Does NOT render the connect step's onboarding chrome (intro heading, + * lock/revoke text, benefits grid, Continue/Skip footer). Hosts compose the + * chrome they need around this component. + */ +export function ScmIntegrationConnect({ + analyticsFlow, + onClearDerivedState, + onIntegrationChange, + onRepositoryChange, + selectedIntegration, + selectedRepository, + maxWidth = SCM_STEP_CONTENT_WIDTH, +}: ScmIntegrationConnectProps) { + const organization = useOrganization(); + const { + scmProviders, + isPending, + isError, + refetch, + refetchIntegrations, + activeIntegrationExisting, + } = useScmProviders(); + + // Pre-warm platform detection so results are cached when the user advances + useScmPlatformDetection(selectedRepository); + + // Derive integration from explicit selection, falling back to existing + const effectiveIntegration = selectedIntegration ?? activeIntegrationExisting; + + useEffect(() => { + trackAnalytics(STEP_VIEWED_EVENT[analyticsFlow], {organization}); + }, [organization, analyticsFlow]); + + const handleInstall = useCallback( + (data: Integration) => { + onIntegrationChange(data); + onRepositoryChange(undefined); + refetchIntegrations(); + }, + [onIntegrationChange, onRepositoryChange, refetchIntegrations] + ); + + if (isPending) { + return ( + + + + ); + } + + if (isError) { + return ( + + {t('Failed to load integrations.')} + + + ); + } + + return effectiveIntegration ? ( + + + + {t( + 'Connected to %s / %s', + effectiveIntegration.provider.name, + effectiveIntegration.name + )} + + + + + ) : ( + + + + ); +} + +const MotionStack = motion.create(Stack); diff --git a/static/app/views/onboarding/scmConnect.tsx b/static/app/views/onboarding/scmConnect.tsx index 92a2c11de0422f..eddf20002502a4 100644 --- a/static/app/views/onboarding/scmConnect.tsx +++ b/static/app/views/onboarding/scmConnect.tsx @@ -1,4 +1,3 @@ -import {useCallback, useEffect} from 'react'; import {LayoutGroup, motion} from 'framer-motion'; import {Button} from '@sentry/scraps/button'; @@ -6,17 +5,12 @@ import {InfoTip} from '@sentry/scraps/info'; import {Flex, Grid, Stack} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconCheckmark, IconClose, IconLock} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Integration, Repository} from 'sentry/types/integrations'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {ScmProviderPills} from './components/scmProviderPills'; -import {ScmRepoSelector} from './components/scmRepoSelector'; +import {ScmIntegrationConnect} from './components/scmIntegrationConnect'; import {ScmStepHeader} from './components/scmStepHeader'; -import {useScmPlatformDetection} from './components/useScmPlatformDetection'; import {useScmProviders} from './components/useScmProviders'; import {SCM_STEP_CONTENT_WIDTH} from './consts'; import type {StepProps} from './types'; @@ -68,35 +62,13 @@ export function ScmConnect({ selectedRepository, genBackButton, }: ScmConnectProps) { - const organization = useOrganization(); - const { - scmProviders, - isPending, - isError, - refetch, - refetchIntegrations, - activeIntegrationExisting, - } = useScmProviders(); - - // Pre-warm platform detection so results are cached when the user advances - useScmPlatformDetection(selectedRepository); - - // Derive integration from explicit selection, falling back to existing + // React Query dedupes with ScmIntegrationConnect's call; only the + // activeIntegrationExisting fallback is needed here for the footer's + // analyticsParams and the "commit auto-detected integration on Continue" + // behavior. + const {activeIntegrationExisting} = useScmProviders(); const effectiveIntegration = selectedIntegration ?? activeIntegrationExisting; - useEffect(() => { - trackAnalytics('onboarding.scm_connect_step_viewed', {organization}); - }, [organization]); - - const handleInstall = useCallback( - (data: Integration) => { - onIntegrationChange(data); - onRepositoryChange(undefined); - refetchIntegrations(); - }, - [onIntegrationChange, onRepositoryChange, refetchIntegrations] - ); - return ( - {isPending ? ( - - - - ) : isError ? ( - - {t('Failed to load integrations.')} - - - ) : effectiveIntegration ? ( - - - - {t( - 'Connected to %s / %s', - effectiveIntegration.provider.name, - effectiveIntegration.name - )} - - - - - ) : ( - - - - )} + Date: Tue, 2 Jun 2026 15:59:29 -0400 Subject: [PATCH 28/46] fix(seer-activity): Use preferred detector instead of issue stream (#116695) Changes how the seer_activity_handler fetches a detector to use the helper instead of limiting it to the issue stream detector. Added a test to ensure the fallback works as expected. --- .../workflow/workflow_activity_handlers.py | 7 ++- .../test_workflow_activity_handlers.py | 50 ++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py index 3eebc62cb66211..88cd0217f9d2b7 100644 --- a/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py +++ b/src/sentry/workflow_engine/handlers/workflow/workflow_activity_handlers.py @@ -6,8 +6,10 @@ from sentry.types.activity import ActivityType from sentry.utils import metrics from sentry.workflow_engine.models import Detector +from sentry.workflow_engine.processors.detector import get_preferred_detector from sentry.workflow_engine.registry import workflow_activity_registry from sentry.workflow_engine.tasks.workflows import process_workflow_activity +from sentry.workflow_engine.types import WorkflowEventData logger = logging.getLogger(__name__) @@ -47,13 +49,16 @@ def seer_activity_handler(group: Group, activity: Activity) -> None: ): return + event_data = WorkflowEventData(event=activity, group=group) try: - detector = Detector.get_issue_stream_detector_for_project(group.project_id) + detector = get_preferred_detector(event_data=event_data) except Detector.DoesNotExist: logger.exception( "workflow_engine.seer_activity_handler.missing_detector", extra=logging_ctx ) return + logging_ctx["detector_id"] = detector.id + logging_ctx["detector_type"] = detector.type process_workflow_activity.delay( activity_id=activity.id, diff --git a/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py b/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py index 22328b223bc485..d366fcde57c380 100644 --- a/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py +++ b/tests/sentry/workflow_engine/handlers/workflow/test_workflow_activity_handlers.py @@ -1,6 +1,8 @@ from unittest import mock from unittest.mock import MagicMock +from sentry.grouping.grouptype import ErrorGroupType +from sentry.incidents.grouptype import MetricIssue from sentry.testutils.cases import TestCase from sentry.testutils.helpers.features import with_feature from sentry.types.activity import ActivityType @@ -54,7 +56,7 @@ def setUp(self) -> None: self.activity = self.create_group_activity( group=self.group, type=ActivityType.SEER_PR_CREATED.value ) - self.detector = self.create_detector(type=IssueStreamGroupType.slug, project=self.project) + self.detector = Detector.objects.get(project=self.project, type=ErrorGroupType.slug) @mock.patch( "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.process_workflow_activity" @@ -100,14 +102,50 @@ def test_skips_unsupported_activity_type( "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.process_workflow_activity" ) @mock.patch( - "sentry.workflow_engine.models.Detector.get_issue_stream_detector_for_project", - side_effect=Exception("DoesNotExist"), + "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.get_preferred_detector", + side_effect=Detector.DoesNotExist, ) - def test_skips_when_no_issue_stream_detector( + def test_skips_when_no_detector( self, mock_get_detector: MagicMock, mock_process_workflow_activity: MagicMock ) -> None: - mock_get_detector.side_effect = Detector.DoesNotExist - seer_activity_handler(self.group, self.activity) mock_process_workflow_activity.delay.assert_not_called() + + @with_feature("organizations:workflow-engine-evaluate-seer-activities") + @mock.patch( + "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.process_workflow_activity" + ) + def test_uses_group_detector(self, mock_process_workflow_activity: MagicMock) -> None: + detector = self.create_detector( + name="linked_detector", type=MetricIssue.slug, project=self.project + ) + self.create_detector_group(detector=detector, group=self.group) + + seer_activity_handler(self.group, self.activity) + + mock_process_workflow_activity.delay.assert_called_once_with( + activity_id=self.activity.id, + group_id=self.group.id, + detector_id=detector.id, + ) + + @with_feature("organizations:workflow-engine-evaluate-seer-activities") + @mock.patch( + "sentry.workflow_engine.handlers.workflow.workflow_activity_handlers.process_workflow_activity" + ) + def test_falls_back_to_issue_stream_detector( + self, mock_process_workflow_activity: MagicMock + ) -> None: + Detector.objects.filter(project=self.project, type=ErrorGroupType.slug).delete() + issue_stream_detector = Detector.objects.get( + project=self.project, type=IssueStreamGroupType.slug + ) + + seer_activity_handler(self.group, self.activity) + + mock_process_workflow_activity.delay.assert_called_once_with( + activity_id=self.activity.id, + group_id=self.group.id, + detector_id=issue_stream_detector.id, + ) From 4163601f44798b91625445c9ddc2df7472148cce Mon Sep 17 00:00:00 2001 From: Mark Story Date: Tue, 2 Jun 2026 15:59:39 -0400 Subject: [PATCH 29/46] chore: Remove sentry_email model from schema (#116631) Drop the sentry_email table now thta it isn't being used anymore. Continues work started in #116245 --- migrations_lockfile.txt | 2 +- .../1104_remove_email_model_drop.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/sentry/migrations/1104_remove_email_model_drop.py diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index 47997be05670e1..a1646a97ca1107 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0017_drop_old_fk_columns -sentry: 1103_backfill_auto_link_repos_by_name +sentry: 1104_remove_email_model_drop social_auth: 0003_social_auth_json_field diff --git a/src/sentry/migrations/1104_remove_email_model_drop.py b/src/sentry/migrations/1104_remove_email_model_drop.py new file mode 100644 index 00000000000000..f367eeff545a89 --- /dev/null +++ b/src/sentry/migrations/1104_remove_email_model_drop.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.14 on 2026-06-01 21:10 + + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.new_migrations.monkey.models import SafeDeleteModel +from sentry.new_migrations.monkey.state import DeletionAction + + +class Migration(CheckedMigration): + # This flag is used to mark that a migration shouldn't be automatically run in production. + # This should only be used for operations where it's safe to run the migration after your + # code has deployed. So this should not be used for most operations that alter the schema + # of a table. + # Here are some things that make sense to mark as post deployment: + # - Large data migrations. Typically we want these to be run manually so that they can be + # monitored and not block the deploy for a long period of time while they run. + # - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to + # run this outside deployments so that we don't block them. Note that while adding an index + # is a schema change, it's completely safe to run the operation after the code has deployed. + # Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment + + is_post_deployment = False + + dependencies = [ + ("sentry", "1103_backfill_auto_link_repos_by_name"), + ] + + operations = [ + SafeDeleteModel(name="Email", deletion_action=DeletionAction.DELETE), + ] From 2cd98d937a811cbb778202bc074c2dcea55b55e2 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 2 Jun 2026 15:02:14 -0500 Subject: [PATCH 30/46] Remove project dependency from performance detection utilities (#116373) These utilities need at most a project-id and organization-id. Requiring the project puts too much burden on callers. We should relax the requirement to enable higher-performance use cases. --- .../issue_detection/performance_detection.py | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/sentry/issue_detection/performance_detection.py b/src/sentry/issue_detection/performance_detection.py index fffc7f36abd020..452281aee62206 100644 --- a/src/sentry/issue_detection/performance_detection.py +++ b/src/sentry/issue_detection/performance_detection.py @@ -333,14 +333,20 @@ def get_merged_settings( assert project is not None # Get WFE-specific settings (analogous to project_option_settings) - wfe_option_settings, wfe_managed_keys = _build_wfe_settings(project) + wfe_option_settings, wfe_managed_keys = _build_wfe_settings(project.id) # Build complete WFE settings by merging in order wfe_settings = {**system_settings, **default_project_settings, **wfe_option_settings} if settings_mode == SettingsMode.COMPARE: # Compare and log differences, but return legacy settings - _log_settings_diff(project, wfe_managed_keys, legacy=legacy_settings, wfe=wfe_settings) + _log_settings_diff( + project.id, + project.organization_id, + wfe_managed_keys, + legacy=legacy_settings, + wfe=wfe_settings, + ) return legacy_settings elif settings_mode == SettingsMode.WFE: # Build hybrid settings with clear priority: @@ -363,9 +369,7 @@ def get_merged_settings( raise ValueError(f"Unknown settings_mode: {settings_mode}") -def _build_wfe_settings( - project: Project, -) -> tuple[dict[str, Any], set[str]]: +def _build_wfe_settings(project_id: int) -> tuple[dict[str, Any], set[str]]: """ Build WFE-specific settings (Detector.config-derived version of sentry:performance_issue_settings). The returned settings dict will only contain keys that are managed by WFE Detectors. @@ -376,7 +380,7 @@ def _build_wfe_settings( """ wfe_option_settings: dict[str, Any] = {} wfe_managed_keys: set[str] = set() - wfe_configs = _get_wfe_detector_configs(project) + wfe_configs = _get_wfe_detector_configs(project_id) # Generate settings only for detectors that have WFE Detector objects for detector_type, wfe_config in wfe_configs.items(): @@ -398,7 +402,8 @@ def _build_wfe_settings( def _log_settings_diff( - project: Project, + project_id: int, + organization_id: int, wfe_managed_keys: set[str], *, legacy: dict[str, Any], @@ -431,15 +436,15 @@ def _log_settings_diff( logger.info( "performance_detector.settings_diff", extra={ - "project_id": project.id, - "organization_id": project.organization_id, + "project_id": project_id, + "organization_id": organization_id, "differences": differences, "diff_count": len(differences), }, ) -def _get_wfe_detector_configs(project: Project) -> dict[DetectorType, dict[str, Any]]: +def _get_wfe_detector_configs(project_id: int) -> dict[DetectorType, dict[str, Any]]: """ Fetch WFE Detector configs for performance detectors. @@ -457,7 +462,7 @@ def _get_wfe_detector_configs(project: Project) -> dict[DetectorType, dict[str, # Per DetectorType configs wfe_configs: dict[DetectorType, dict[str, Any]] = {} for detector in Detector.objects.filter( - project=project, + project_id=project_id, type__in=wfe_types, ): # Map WFE detector type to our DetectorType enum @@ -474,16 +479,18 @@ def _get_wfe_detector_configs(project: Project) -> dict[DetectorType, dict[str, return wfe_configs -def _get_wfe_detectors_by_type(project: Project) -> dict[str, Detector]: +def _get_wfe_detectors_by_type(project_id: int) -> dict[str, Detector]: """Fetch all performance WFE detectors for a project, keyed by detector type.""" return { d.type: d - for d in Detector.objects.filter(project=project, type__in=PERFORMANCE_WFE_DETECTOR_TYPES) + for d in Detector.objects.filter( + project_id=project_id, type__in=PERFORMANCE_WFE_DETECTOR_TYPES + ) } def sync_project_options_to_wfe_detectors( - project: Project, settings_data: dict[str, Any] + project_id: int, settings_data: dict[str, Any] ) -> dict[DetectorType, bool]: """ Sync ProjectOption settings to WFE Detector configs. @@ -495,7 +502,7 @@ def sync_project_options_to_wfe_detectors( Returns dict of DetectorType -> bool indicating which detectors were updated. """ updated: dict[DetectorType, bool] = {} - existing_detectors = _get_wfe_detectors_by_type(project) + existing_detectors = _get_wfe_detectors_by_type(project_id) for detector_type, mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.items(): detector = existing_detectors.get(mapping.wfe_detector_type) @@ -529,7 +536,7 @@ def sync_project_options_to_wfe_detectors( return updated -def reset_wfe_detector_configs(project: Project) -> dict[DetectorType, bool]: +def reset_wfe_detector_configs(project_id: int) -> dict[DetectorType, bool]: """ Reset WFE Detector configs to defaults by clearing config on enabled detectors. @@ -540,7 +547,7 @@ def reset_wfe_detector_configs(project: Project) -> dict[DetectorType, bool]: Returns dict of DetectorType -> bool indicating which detectors were updated. """ updated: dict[DetectorType, bool] = {} - existing_detectors = _get_wfe_detectors_by_type(project) + existing_detectors = _get_wfe_detectors_by_type(project_id) for detector_type, mapping in PERFORMANCE_DETECTOR_CONFIG_MAPPINGS.items(): detector = existing_detectors.get(mapping.wfe_detector_type) @@ -581,7 +588,7 @@ def update_performance_settings( project.update_option(SETTINGS_PROJECT_OPTION_KEY, settings) if sync_detectors: - return sync_project_options_to_wfe_detectors(project, settings) + return sync_project_options_to_wfe_detectors(project.id, settings) return {} @@ -607,7 +614,7 @@ def reset_performance_settings( project.update_option(SETTINGS_PROJECT_OPTION_KEY, unchanged_options) if sync_detectors: - return reset_wfe_detector_configs(project) + return reset_wfe_detector_configs(project.id) return {} From 131891a5f34fd33f156373e53a3aa425d947b053 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Tue, 2 Jun 2026 16:10:34 -0400 Subject: [PATCH 31/46] chore(cells) Refactor region utils (#116697) Improve region utils and use them more consistently. This is clean up work and prep to rework what regions are shown in different parts of the UI. Refs INFRENG-332 --- static/app/utils/regions/index.spec.tsx | 4 ++-- static/app/utils/regions/index.tsx | 17 ++++++++++------- .../organizationSettingsForm.spec.tsx | 1 + .../components/customers/customerOverview.tsx | 12 +++++------- static/gsAdmin/components/resultGrid.tsx | 14 +++++++------- static/gsAdmin/views/customerDetails.tsx | 3 ++- .../views/generateSpikeProjectionsForBatch.tsx | 4 ++-- static/gsAdmin/views/home.tsx | 4 ++-- static/gsAdmin/views/invoiceComparison.tsx | 4 ++-- static/gsAdmin/views/invoiceDetails.tsx | 4 ++-- static/gsAdmin/views/launchpadAdminPage.tsx | 4 ++-- .../gsAdmin/views/relocationArtifactDetails.tsx | 4 ++-- static/gsAdmin/views/relocationCreate.tsx | 6 +++--- static/gsAdmin/views/relocationDetails.tsx | 7 ++++--- static/gsAdmin/views/seerAdminPage.tsx | 4 ++-- .../legalAndCompliance/legalAndCompliance.tsx | 2 +- 16 files changed, 49 insertions(+), 45 deletions(-) diff --git a/static/app/utils/regions/index.spec.tsx b/static/app/utils/regions/index.spec.tsx index 964ceb893e6b0e..c9148cf2268888 100644 --- a/static/app/utils/regions/index.spec.tsx +++ b/static/app/utils/regions/index.spec.tsx @@ -21,7 +21,7 @@ describe('getRegionUrlOptions', () => { ]); const res = getRegionUrlOptions([ - {name: 'us', url: 'https://us.sentry.io', displayName: 'us'}, + {name: 'us', url: 'https://us.sentry.io', displayName: 'us', label: 'us'}, ]); expect(res).toHaveLength(2); expect(res[0]).toEqual({ @@ -32,7 +32,7 @@ describe('getRegionUrlOptions', () => { // Excluding the only included option = empty set. const none = getRegionUrlOptions( - [{name: 'us', url: 'https://us.sentry.io', displayName: 'us'}], + [{name: 'us', url: 'https://us.sentry.io', displayName: 'us', label: 'us'}], ['us'] ); expect(none).toHaveLength(0); diff --git a/static/app/utils/regions/index.tsx b/static/app/utils/regions/index.tsx index a7c794019970ee..28c70108dfc987 100644 --- a/static/app/utils/regions/index.tsx +++ b/static/app/utils/regions/index.tsx @@ -18,6 +18,7 @@ const RegionFlagIndicator: Record = { interface RegionData { displayName: string; + label: string; name: string; url: string; flag?: string; @@ -27,9 +28,9 @@ function getRegionDisplayName(region: Region): string { return RegionDisplayName[region.name.toUpperCase()] ?? region.name; } -function getRegionFlagIndicator(region: Region): string | undefined { +function getRegionFlagIndicator(region: Region): string { const regionName = region.name.toUpperCase(); - return RegionFlagIndicator[regionName]; + return RegionFlagIndicator[regionName] ?? ''; } export function getRegionDataFromOrganization( @@ -38,7 +39,6 @@ export function getRegionDataFromOrganization( const {regionUrl} = organization.links; const regions = getRegions(); - const region = regions.find(value => { return value.url === regionUrl; }); @@ -46,10 +46,13 @@ export function getRegionDataFromOrganization( if (!region) { return undefined; } + const flag = getRegionFlagIndicator(region); + const displayName = getRegionDisplayName(region); return { - flag: getRegionFlagIndicator(region), - displayName: getRegionDisplayName(region), + flag, + displayName, + label: `${flag} ${displayName}`, name: region.name, url: region.url, }; @@ -84,7 +87,7 @@ export function getRegionUrlOptions( const {url} = region; return { value: url, - label: `${getRegionFlagIndicator(region) || ''} ${getRegionDisplayName(region)}`, + label: `${getRegionFlagIndicator(region)} ${getRegionDisplayName(region)}`, }; }); } @@ -103,7 +106,7 @@ export function getRegionNameOptions(): Array> { .map(region => { return { value: region.name, - label: `${getRegionFlagIndicator(region) || ''} ${getRegionDisplayName(region)}`, + label: `${getRegionFlagIndicator(region)} ${getRegionDisplayName(region)}`, }; }); } diff --git a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx index 835c69f398508b..da670a6952aadc 100644 --- a/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx +++ b/static/app/views/settings/organizationGeneralSettings/organizationSettingsForm.spec.tsx @@ -250,6 +250,7 @@ describe('OrganizationSettingsForm', () => { name: 'de', displayName: 'Europe (Frankfurt)', url: 'https://sentry.de.example.com', + label: '🇪🇺 Europe (Frankfurt)', })); render( diff --git a/static/gsAdmin/components/customers/customerOverview.tsx b/static/gsAdmin/components/customers/customerOverview.tsx index 69763cc7464bb3..bec9b61c4aba95 100644 --- a/static/gsAdmin/components/customers/customerOverview.tsx +++ b/static/gsAdmin/components/customers/customerOverview.tsx @@ -15,6 +15,7 @@ import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {useApiQuery} from 'sentry/utils/queryClient'; +import {getRegions} from 'sentry/utils/regions'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import {openAdminConfirmModal} from 'admin/components/adminConfirmationModal'; @@ -474,13 +475,10 @@ export function CustomerOverview({customer, onAction, organization}: Props) { orgUrl = `${organization.links.organizationUrl}/issues/`; } - const regionMap = ConfigStore.get('regions').reduce>( - (acc, region) => { - acc[region.url] = region.name; - return acc; - }, - {} - ); + const regionMap = getRegions().reduce>((acc, region) => { + acc[region.url] = region.name; + return acc; + }, {}); const region = regionMap[organization.links.regionUrl] ?? '??'; const productTrialCategories = Object.values(BILLED_DATA_CATEGORY_INFO).filter( diff --git a/static/gsAdmin/components/resultGrid.tsx b/static/gsAdmin/components/resultGrid.tsx index 51de8a84e4badd..9477bce4337914 100644 --- a/static/gsAdmin/components/resultGrid.tsx +++ b/static/gsAdmin/components/resultGrid.tsx @@ -16,8 +16,8 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; import {PanelHeader} from 'sentry/components/panels/panelHeader'; import {IconList, IconSearch} from 'sentry/icons'; -import {ConfigStore} from 'sentry/stores/configStore'; import type {Region} from 'sentry/types/system'; +import {getRegions} from 'sentry/utils/regions'; import {useApi} from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; import type {ReactRouter3Navigate} from 'sentry/utils/useNavigate'; @@ -253,6 +253,7 @@ class ResultGridImpl extends Component { const {cursor, query, sortBy, regionUrl} = queryParams; const needsRegion = this.props.isRegional || this.props.isCellScoped; + const regions = getRegions(); this.state = { rows: [], @@ -263,8 +264,8 @@ class ResultGridImpl extends Component { query: extractQuery(query), region: needsRegion ? regionUrl - ? ConfigStore.get('regions').find((r: any) => r.url === extractQuery(regionUrl)) - : ConfigStore.get('regions')[0] + ? regions.find((r: any) => r.url === extractQuery(regionUrl)) + : regions[0] : undefined, sortBy: extractQuery(sortBy, this.props.defaultSort), filters: Object.assign({}, queryParams), @@ -483,6 +484,7 @@ class ResultGridImpl extends Component { resultTable ); + const regions = getRegions(); const needsRegion = this.props.isRegional || this.props.isCellScoped; return ( @@ -494,14 +496,12 @@ class ResultGridImpl extends Component { )} value={this.state.region ? this.state.region.url : undefined} - options={ConfigStore.get('regions').map((r: any) => ({ + options={regions.map((r: any) => ({ label: r.name, value: r.url, }))} onChange={opt => { - const region = ConfigStore.get('regions').find( - (r: any) => r.url === opt.value - ); + const region = regions.find((r: any) => r.url === opt.value); if (region === undefined) { return; } diff --git a/static/gsAdmin/views/customerDetails.tsx b/static/gsAdmin/views/customerDetails.tsx index 0f2d0034a802e9..a9b6b14dfac548 100644 --- a/static/gsAdmin/views/customerDetails.tsx +++ b/static/gsAdmin/views/customerDetails.tsx @@ -24,6 +24,7 @@ import {apiOptions} from 'sentry/utils/api/apiOptions'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import type {ApiQueryKey} from 'sentry/utils/queryClient'; import {fetchMutation, setApiQueryData, useApiQuery} from 'sentry/utils/queryClient'; +import {getRegions} from 'sentry/utils/regions'; import type {RequestError} from 'sentry/utils/requestError/requestError'; import {useApi} from 'sentry/utils/useApi'; import {useLocation} from 'sentry/utils/useLocation'; @@ -322,7 +323,7 @@ export function CustomerDetails() { } }; - const regionMap = ConfigStore.get('regions').reduce>( + const regionMap = getRegions().reduce>( (acc: any, region: any) => { acc[region.url] = region.name; return acc; diff --git a/static/gsAdmin/views/generateSpikeProjectionsForBatch.tsx b/static/gsAdmin/views/generateSpikeProjectionsForBatch.tsx index 263ec3c804255c..c7265bccbc55ce 100644 --- a/static/gsAdmin/views/generateSpikeProjectionsForBatch.tsx +++ b/static/gsAdmin/views/generateSpikeProjectionsForBatch.tsx @@ -9,15 +9,15 @@ import {Input} from '@sentry/scraps/input'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {ConfigStore} from 'sentry/stores/configStore'; import {getFormat} from 'sentry/utils/dates'; import {fetchMutation} from 'sentry/utils/queryClient'; +import {getRegions} from 'sentry/utils/regions'; import {PageHeader} from 'admin/components/pageHeader'; export function GenerateSpikeProjectionsForBatch() { const [batchId, setBatchId] = useState(null); - const regions = ConfigStore.get('regions'); + const regions = getRegions(); const [region, setRegion] = useState(regions[0] ?? null); const {mutate} = useMutation({ diff --git a/static/gsAdmin/views/home.tsx b/static/gsAdmin/views/home.tsx index 642b37db6242a4..2469045a3fe055 100644 --- a/static/gsAdmin/views/home.tsx +++ b/static/gsAdmin/views/home.tsx @@ -8,7 +8,7 @@ import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {UserBadge} from 'sentry/components/idBadge/userBadge'; import {Truncate} from 'sentry/components/truncate'; -import {ConfigStore} from 'sentry/stores/configStore'; +import {getRegions} from 'sentry/utils/regions'; import {useNavigate} from 'sentry/utils/useNavigate'; import {DebounceSearch} from 'admin/components/debounceSearch'; @@ -16,7 +16,7 @@ import {Overview} from 'admin/views/overview'; export function HomePage() { const navigate = useNavigate(); - const regions = ConfigStore.get('regions'); + const regions = getRegions(); const [oldSplash, setOldSplash] = useState(false); const [regionUrl, setRegionUrl] = useState(regions[0]!.url); const selectedRegion = regions.find((region: any) => region.url === regionUrl); diff --git a/static/gsAdmin/views/invoiceComparison.tsx b/static/gsAdmin/views/invoiceComparison.tsx index f4fbb264fe7428..0e96a45e1c19ce 100644 --- a/static/gsAdmin/views/invoiceComparison.tsx +++ b/static/gsAdmin/views/invoiceComparison.tsx @@ -17,8 +17,8 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; import {PanelBody} from 'sentry/components/panels/panelBody'; import {PanelHeader} from 'sentry/components/panels/panelHeader'; -import {ConfigStore} from 'sentry/stores/configStore'; import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {getRegions} from 'sentry/utils/regions'; type RowStatus = 'match' | 'mismatch' | 'legacy_only' | 'platform_only'; @@ -116,7 +116,7 @@ function localInputToUtcIso(value: string): string { } export function InvoiceComparison() { - const regions = ConfigStore.get('regions'); + const regions = getRegions(); const [region, setRegion] = useState(regions[0] ?? null); const [startInput, setStartInput] = useState(hoursAgoLocal(24)); const [endInput, setEndInput] = useState(nowLocal()); diff --git a/static/gsAdmin/views/invoiceDetails.tsx b/static/gsAdmin/views/invoiceDetails.tsx index ed50cf1c2087f8..ee6efbcd903fef 100644 --- a/static/gsAdmin/views/invoiceDetails.tsx +++ b/static/gsAdmin/views/invoiceDetails.tsx @@ -8,9 +8,9 @@ import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicato import {DateTime} from 'sentry/components/dateTime'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {ConfigStore} from 'sentry/stores/configStore'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {setApiQueryData, useApiQuery, type ApiQueryKey} from 'sentry/utils/queryClient'; +import {getRegions} from 'sentry/utils/regions'; import {useApi} from 'sentry/utils/useApi'; import {useParams} from 'sentry/utils/useParams'; @@ -32,7 +32,7 @@ export function InvoiceDetails() { orgId: string; region: string; }>(); - const regionInfo = ConfigStore.get('regions').find( + const regionInfo = getRegions().find( (r: any) => r.name.toLowerCase() === region.toLowerCase() ); const api = useApi({persistInFlight: true}); diff --git a/static/gsAdmin/views/launchpadAdminPage.tsx b/static/gsAdmin/views/launchpadAdminPage.tsx index e8c9386d3c50f4..57e8cf914c4063 100644 --- a/static/gsAdmin/views/launchpadAdminPage.tsx +++ b/static/gsAdmin/views/launchpadAdminPage.tsx @@ -12,9 +12,9 @@ import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Heading, Text} from '@sentry/scraps/text'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {ConfigStore} from 'sentry/stores/configStore'; import {downloadPreprodArtifact} from 'sentry/utils/downloadPreprodArtifact'; import {fetchMutation} from 'sentry/utils/queryClient'; +import {getRegions} from 'sentry/utils/regions'; import {useApi} from 'sentry/utils/useApi'; import {openAdminConfirmModal} from 'admin/components/adminConfirmationModal'; @@ -28,7 +28,7 @@ export function LaunchpadAdminPage() { const [batchDeleteArtifactIds, setBatchDeleteArtifactIds] = useState(''); const [downloadArtifactId, setDownloadArtifactId] = useState(''); const [fetchedArtifactInfo, setFetchedArtifactInfo] = useState(null); - const regions = ConfigStore.get('regions'); + const regions = getRegions(); const [region, setRegion] = useState(regions[0] ?? null); const {mutate: rerunAnalysis} = useMutation({ diff --git a/static/gsAdmin/views/relocationArtifactDetails.tsx b/static/gsAdmin/views/relocationArtifactDetails.tsx index f89819d8b6c483..9e10939972327a 100644 --- a/static/gsAdmin/views/relocationArtifactDetails.tsx +++ b/static/gsAdmin/views/relocationArtifactDetails.tsx @@ -3,9 +3,9 @@ import {CodeBlock} from '@sentry/scraps/code'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconFile} from 'sentry/icons/iconFile'; -import {ConfigStore} from 'sentry/stores/configStore'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {useApiQuery} from 'sentry/utils/queryClient'; +import {getRegions} from 'sentry/utils/regions'; import {useParams} from 'sentry/utils/useParams'; import {DetailsPage} from 'admin/components/detailsPage'; @@ -21,7 +21,7 @@ export function RelocationArtifactDetails() { regionName: string; relocationUuid: string; }>(); - const region = ConfigStore.get('regions').find((r: any) => r.name === regionName); + const region = getRegions().find((r: any) => r.name === regionName); const {data, isPending, isError} = useApiQuery( [ diff --git a/static/gsAdmin/views/relocationCreate.tsx b/static/gsAdmin/views/relocationCreate.tsx index 59e7b12f81b132..6af216ec017833 100644 --- a/static/gsAdmin/views/relocationCreate.tsx +++ b/static/gsAdmin/views/relocationCreate.tsx @@ -8,7 +8,7 @@ import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {Client} from 'sentry/api'; -import {ConfigStore} from 'sentry/stores/configStore'; +import {getRegions} from 'sentry/utils/regions'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {useApi} from 'sentry/utils/useApi'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -29,7 +29,7 @@ function RelocationForm() { const promoCodeApi = useApi({ api: new Client({baseUrl: ''}), }); - const regions = ConfigStore.get('regions'); + const regions = getRegions(); const inputFileRef = useRef(null); const [file, setFile] = useState(); const [region, setRegion] = useState(regions[0]!); @@ -124,7 +124,7 @@ function RelocationForm() { value: r.url, }))} onChange={opt => { - const reg = ConfigStore.get('regions').find((r: any) => r.url === opt.value); + const reg = regions.find((r: any) => r.url === opt.value); if (reg === undefined) { return; } diff --git a/static/gsAdmin/views/relocationDetails.tsx b/static/gsAdmin/views/relocationDetails.tsx index 46cc302e2e4039..0270b075826c76 100644 --- a/static/gsAdmin/views/relocationDetails.tsx +++ b/static/gsAdmin/views/relocationDetails.tsx @@ -16,10 +16,10 @@ import {UserBadge} from 'sentry/components/idBadge/userBadge'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Truncate} from 'sentry/components/truncate'; -import {ConfigStore} from 'sentry/stores/configStore'; import type {Organization} from 'sentry/types/organization'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; import {useApiQuery} from 'sentry/utils/queryClient'; +import {getRegions} from 'sentry/utils/regions'; import {useApi} from 'sentry/utils/useApi'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useParams} from 'sentry/utils/useParams'; @@ -181,7 +181,8 @@ export function RelocationDetails() { const [artifactsState, setArtifactsState] = useState(ArtifactsState.DISABLED); const navigate = useNavigate(); - const region = ConfigStore.get('regions').find((r: any) => r.name === regionName); + const regions = getRegions(); + const region = regions.find((r: any) => r.name === regionName); const regionClient = new Client({baseUrl: `${region?.url || ''}/api/0`}); const regionApi = useApi({api: regionClient}); @@ -207,7 +208,7 @@ export function RelocationDetails() { const relocationData = { ...data, - region: ConfigStore.get('regions').find((r: any) => r.name === regionName) || { + region: regions.find((r: any) => r.name === regionName) || { name: regionName, url: '', }, diff --git a/static/gsAdmin/views/seerAdminPage.tsx b/static/gsAdmin/views/seerAdminPage.tsx index d5db61f96f84da..9ae2151659a745 100644 --- a/static/gsAdmin/views/seerAdminPage.tsx +++ b/static/gsAdmin/views/seerAdminPage.tsx @@ -10,9 +10,9 @@ import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Heading, Text} from '@sentry/scraps/text'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {ConfigStore} from 'sentry/stores/configStore'; import type {Region} from 'sentry/types/system'; import {fetchMutation} from 'sentry/utils/queryClient'; +import {getRegions} from 'sentry/utils/regions'; import {PageHeader} from 'admin/components/pageHeader'; @@ -20,7 +20,7 @@ export function SeerAdminPage() { const [organizationId, setOrganizationId] = useState(''); const [dryRun, setDryRun] = useState(false); const [maxCandidates, setMaxCandidates] = useState(''); - const regions = ConfigStore.get('regions'); + const regions = getRegions(); const [region, setRegion] = useState(regions[0] ?? null); const {mutate: triggerNightShift, isPending: isNightShiftPending} = useMutation({ diff --git a/static/gsApp/views/legalAndCompliance/legalAndCompliance.tsx b/static/gsApp/views/legalAndCompliance/legalAndCompliance.tsx index e0d7e321b739a6..7aa0f1daa13e4d 100644 --- a/static/gsApp/views/legalAndCompliance/legalAndCompliance.tsx +++ b/static/gsApp/views/legalAndCompliance/legalAndCompliance.tsx @@ -43,7 +43,7 @@ export default function LegalAndCompliance() { - {`${regionData.flag ?? ''} ${regionData.displayName}`} + {regionData.label} From 62103a51ed6957a7141dadd6fab76a110dd5e186 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 2 Jun 2026 13:11:52 -0700 Subject: [PATCH 32/46] feat(issues): Improve merged issues drawer (#116634) The merged issues drawer was leading with fingerprint hashes, which are mostly useless unless you already know what they mean. This moves the latest event and merge source into the row, collapses the raw fingerprint by default, and keeps the hash visually attached underneath. Also keys the drawer resize state, requests `full=0`, and puts the visible count into the pagination caption instead of the title. before image after image fixes JAVASCRIPT-39NY --- .../issueDetails/groupMerged/index.spec.tsx | 61 ++--- .../views/issueDetails/groupMerged/index.tsx | 60 ++--- .../groupMerged/mergedIssuesDrawer.spec.tsx | 45 ---- .../groupMerged/mergedIssuesDrawer.tsx | 5 +- .../issueDetails/groupMerged/mergedItem.tsx | 220 +++++++++++------- .../issueDetails/groupMerged/mergedList.tsx | 37 +-- .../groupMerged/mergedToolbar.tsx | 4 +- .../groupMerged/useGroupMerged.tsx | 32 ++- .../hooks/useMergedIssuesDrawer.tsx | 4 +- 9 files changed, 247 insertions(+), 221 deletions(-) delete mode 100644 static/app/views/issueDetails/groupMerged/mergedIssuesDrawer.spec.tsx diff --git a/static/app/views/issueDetails/groupMerged/index.spec.tsx b/static/app/views/issueDetails/groupMerged/index.spec.tsx index 062fa791276756..71d05d0c2a1b95 100644 --- a/static/app/views/issueDetails/groupMerged/index.spec.tsx +++ b/static/app/views/issueDetails/groupMerged/index.spec.tsx @@ -1,15 +1,19 @@ import {DetailedEventsFixture} from 'sentry-fixture/events'; import {GroupFixture} from 'sentry-fixture/group'; -import {LocationFixture} from 'sentry-fixture/locationFixture'; +import {ProjectFixture} from 'sentry-fixture/project'; -import {initializeOrg} from 'sentry-test/initializeOrg'; -import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; import {GroupMergedView} from 'sentry/views/issueDetails/groupMerged'; -describe('Issues -> Merged View', () => { +describe('GroupMergedView', () => { const events = DetailedEventsFixture(); const group = GroupFixture(); + const project = ProjectFixture(); + const hashesUrl = `/organizations/org-slug/issues/${group.id}/hashes/`; + const pageLinks = + '; rel="previous"; results="false"; cursor="0:0:1", ' + + '; rel="next"; results="false"; cursor="0:50:0"'; const mergedFingerprints = [ { latestEvent: events[0], @@ -24,36 +28,41 @@ describe('Issues -> Merged View', () => { beforeEach(() => { MockApiClient.clearMockResponses(); - MockApiClient.addMockResponse({ - url: `/organizations/org-slug/issues/${group.id}/hashes/`, - body: mergedFingerprints, - }); }); it('renders merged groups', async () => { - const {organization, project} = initializeOrg({ - router: { - params: {groupId: 'groupId'}, - }, + MockApiClient.addMockResponse({ + url: hashesUrl, + body: mergedFingerprints, + headers: {Link: pageLinks}, }); - render( - , - { - organization, - } - ); + render(); - const links = await screen.findAllByRole('button', {name: 'View latest event'}); + const links = await screen.findAllByRole('link', {name: 'Latest event'}); expect(links).toHaveLength(mergedFingerprints.length); + expect(links[0]).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/268/events/904/?project=1&referrer=merged-item' + ); + const showFingerprint = screen.getByRole('button', { + name: `Show ${mergedFingerprints[0]!.id} fingerprints`, + }); const title = await screen.findByText('Fingerprints included in this issue'); - expect(title.parentElement).toHaveTextContent( - 'Fingerprints included in this issue (2)' - ); + expect(title).toBeInTheDocument(); + expect(screen.getByText(/Merged by Sentry/)).toBeInTheDocument(); + expect(screen.queryByText(mergedFingerprints[0]!.id)).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { + name: `Copy fingerprint ${mergedFingerprints[0]!.id} to clipboard`, + }) + ).not.toBeInTheDocument(); + + await userEvent.click(showFingerprint); + + expect( + await screen.findByText(`Fingerprint ${mergedFingerprints[0]!.id}`) + ).toBeInTheDocument(); }); }); diff --git a/static/app/views/issueDetails/groupMerged/index.tsx b/static/app/views/issueDetails/groupMerged/index.tsx index 5fd1eb590fe83f..3a13c42d7b4eac 100644 --- a/static/app/views/issueDetails/groupMerged/index.tsx +++ b/static/app/views/issueDetails/groupMerged/index.tsx @@ -1,10 +1,9 @@ -import {Fragment} from 'react'; -import styled from '@emotion/styled'; -import type {Location} from 'history'; +import {Stack} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Heading, Text} from '@sentry/scraps/text'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {QueryCount} from 'sentry/components/queryCount'; import {t, tct} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; @@ -21,10 +20,12 @@ import { type Props = { groupId: Group['id']; - location: Location; project: Project; }; +const MERGED_ISSUES_DOCS_LINK = + 'https://docs.sentry.io/product/issues/grouping-and-fingerprints/#merging-similar-issues'; + interface GroupMergedContentProps { error: boolean; fingerprints: Fingerprint[]; @@ -36,12 +37,11 @@ interface GroupMergedContentProps { pageLinks?: string; } -export function GroupMergedView({project, groupId, location}: Props) { +export function GroupMergedView({project, groupId}: Props) { const organization = useOrganization(); const {dataUpdatedAt, error, fingerprints, loading, pageLinks, refetch} = useGroupMergedHashes({ groupId, - location, organization, }); @@ -97,39 +97,40 @@ function GroupMergedContent({ }; const isError = error && !loading; - const isLoadedSuccessfully = !isError && !loading; + const isLoadedSuccessfully = !error && !loading; return ( - - - - {tct('Fingerprints included in this issue [count]', { - count: <QueryCount count={fingerprintsWithLatestEvent.length} />, - })} - - + + + + {t('Fingerprints included in this issue')} + + { // TODO: Once clickhouse is upgraded and the lag is no longer an issue, revisit this wording. // See https://github.com/getsentry/sentry/issues/56334. - t( - 'This is an experimental feature. All changes may take up to 24 hours take effect.' + tct( + 'These fingerprints identify events that have been merged into this issue. Changes may take up to 24 hours to take effect. [learnMore:Learn more]', + { + learnMore: , + } ) } - - + + {loading && } {isError && ( refetch()} + onRetry={refetch} /> )} {isLoadedSuccessfully && ( )} - + ); } - -const Title = styled('h4')` - font-size: ${p => p.theme.font.size.lg}; - margin-bottom: ${p => p.theme.space.sm}; -`; - -const HeaderWrapper = styled('div')` - margin-bottom: ${p => p.theme.space.xl}; - - small { - color: ${p => p.theme.tokens.content.secondary}; - } -`; diff --git a/static/app/views/issueDetails/groupMerged/mergedIssuesDrawer.spec.tsx b/static/app/views/issueDetails/groupMerged/mergedIssuesDrawer.spec.tsx deleted file mode 100644 index 94eceef977dbf1..00000000000000 --- a/static/app/views/issueDetails/groupMerged/mergedIssuesDrawer.spec.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {EventFixture} from 'sentry-fixture/event'; -import {GroupFixture} from 'sentry-fixture/group'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {ProjectFixture} from 'sentry-fixture/project'; - -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {GroupStore} from 'sentry/stores/groupStore'; -import {ProjectsStore} from 'sentry/stores/projectsStore'; -import {MergedIssuesDrawer} from 'sentry/views/issueDetails/groupMerged/mergedIssuesDrawer'; - -describe('MergedIssuesDrawer', () => { - const organization = OrganizationFixture(); - const project = ProjectFixture(); - const group = GroupFixture(); - const event = EventFixture(); - - beforeEach(() => { - MockApiClient.clearMockResponses(); - ProjectsStore.loadInitialData([project]); - GroupStore.init(); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/issues/${group.id}/hashes/`, - body: [ - { - latestEvent: event, - id: '2c4887696f708c476a81ce4e834c4b02', - mergedBySeer: true, - }, - ], - method: 'GET', - }); - }); - - it('renders the content as expected', async () => { - render(, {organization}); - - expect( - await screen.findByRole('heading', {name: 'Merged Issues'}) - ).toBeInTheDocument(); - expect(screen.getByText('Fingerprints included in this issue')).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Close Drawer'})).toBeInTheDocument(); - }); -}); diff --git a/static/app/views/issueDetails/groupMerged/mergedIssuesDrawer.tsx b/static/app/views/issueDetails/groupMerged/mergedIssuesDrawer.tsx index d2e58bdb8218d3..1852a4edaa045a 100644 --- a/static/app/views/issueDetails/groupMerged/mergedIssuesDrawer.tsx +++ b/static/app/views/issueDetails/groupMerged/mergedIssuesDrawer.tsx @@ -13,12 +13,9 @@ import { import {t} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; import type {Project} from 'sentry/types/project'; -import {useLocation} from 'sentry/utils/useLocation'; import {GroupMergedView} from 'sentry/views/issueDetails/groupMerged'; export function MergedIssuesDrawer({group, project}: {group: Group; project: Project}) { - const location = useLocation(); - return ( @@ -40,7 +37,7 @@ export function MergedIssuesDrawer({group, project}: {group: Group; project: Pro
{t('Merged Issues')}
- +
); diff --git a/static/app/views/issueDetails/groupMerged/mergedItem.tsx b/static/app/views/issueDetails/groupMerged/mergedItem.tsx index 8be7131862b643..e3403b7aeecc89 100644 --- a/static/app/views/issueDetails/groupMerged/mergedItem.tsx +++ b/static/app/views/issueDetails/groupMerged/mergedItem.tsx @@ -1,41 +1,41 @@ -import {useTheme} from '@emotion/react'; +import {Fragment} from 'react'; +import {css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; -import {Button, LinkButton} from '@sentry/scraps/button'; +import {Button} from '@sentry/scraps/button'; import {Checkbox} from '@sentry/scraps/checkbox'; -import {Flex} from '@sentry/scraps/layout'; +import {Flex, Grid} from '@sentry/scraps/layout'; +import {Link} from '@sentry/scraps/link'; import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; -import {IconChevron, IconLink} from 'sentry/icons'; +import {TimeSince} from 'sentry/components/timeSince'; +import {IconChevron} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {createIssueLink} from 'sentry/views/issueList/utils'; import {type FingerprintWithLatestEvent, type GroupMergedState} from './useGroupMerged'; interface Props { + canSelect: boolean; fingerprint: FingerprintWithLatestEvent; state: GroupMergedState; toggleCollapsed: (fingerprintId: string) => void; toggleSelected: (fingerprintId: string, eventId: string) => void; - totalFingerprint: number; } export function MergedItem({ + canSelect, fingerprint, state, toggleCollapsed, toggleSelected, - totalFingerprint, }: Props) { - const theme = useTheme(); const organization = useOrganization(); - const location = useLocation(); + const theme = useTheme(); const stateForId = state.fingerprintState.get(fingerprint.id); const busy = Boolean(stateForId?.busy); - const collapsed = Boolean(stateForId?.collapsed); + const collapsed = stateForId?.collapsed ?? state.unmergeLastCollapsed; const checked = Boolean(stateForId?.checked); function handleToggleEvents() { @@ -49,50 +49,90 @@ export function MergedItem({ return; } - // clicking anywhere in the row will toggle the checkbox toggleSelected(fingerprint.id, latestEvent.id); } - function handleCheckClick() { - // noop because of react warning about being a controlled input without `onChange` - // we handle change via row click - } - const {latestEvent, id} = fingerprint; - const checkboxDisabled = busy || totalFingerprint === 1; - - const issueLink = createIssueLink({ - organization, - location, - data: latestEvent, - eventId: latestEvent.id, - referrer: 'merged-item', - }); + const checkboxDisabledReason = canSelect + ? undefined + : t('To check, the list must contain 2 or more items'); + const checkboxDisabled = busy || checkboxDisabledReason !== undefined; + const latestEventTimestamp = latestEvent.dateCreated ?? latestEvent.dateReceived; return ( - - + + - + + + - {id} - {fingerprint.mergedBySeer && ' (merged by Sentry)'} - + + + + {latestEvent.title} + + {(latestEventTimestamp || fingerprint.mergedBySeer) && ( + + {latestEventTimestamp && ( + + + + {t('Latest event')} + + + + + + + )} + {fingerprint.mergedBySeer && ( + + + {latestEventTimestamp && } + {t('Merged by Sentry')} + + + )} + + )} + +