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 db051592becf1e..68dde83f18cafd 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -28,14 +28,10 @@ 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 }} - jest_test_matrix: ${{ steps.jest_matrix_config.outputs.jest_test_matrix }} + jest_test_files: ${{ steps.jest_test_config.outputs.jest_test_files }} + jest_test_matrix: ${{ steps.jest_test_config.outputs.jest_test_matrix }} steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: @@ -53,8 +49,8 @@ jobs: # Merge base of those two is what Jest --changedSince needs. # If merge base can't be computed or non-frontend files changed, output is empty # and the Jest job will run the full test suite instead. - - name: Get merge base for changedSince - id: merge_base + - name: Jest Test Config + id: jest_test_config if: github.event_name == 'pull_request' run: | MERGE_BASE=$(git merge-base HEAD^1 HEAD^2 2>/dev/null) || true @@ -72,16 +68,18 @@ jobs: echo "Could not compute merge base — running full Jest suite" STRATEGY="no_merge_base" fi - echo "merge_base=${MERGE_BASE:-}" >> "$GITHUB_OUTPUT" - echo "merge_base_strategy=${STRATEGY}" >> "$GITHUB_OUTPUT" - - name: Jest Matrix Config - id: jest_matrix_config - run: | - RUNNERS=8 - if [ "${{ steps.merge_base.outputs.merge_base_strategy }}" == "changedSince" ]; then - RUNNERS=1 + if [ "$STRATEGY" == "changedSince" ]; then + JEST_TESTS="$(jest --listTests --json --findRelatedTests ${{ steps.changes.outputs.frontend_all_files }} | jq -r '.')"Expand comment + else + JEST_TESTS="$(jest --listTests --json)" fi + echo "jest_test_files=${JEST_TESTS}" >> "$GITHUB_OUTPUT" + + # Divide jest_test_files_length by RUNNER_CHUNK_SIZE (rounded up) + RUNNER_CHUNK_SIZE=250 + JEST_TESTS_LENGTH=${#JEST_TESTS[@]} + RUNNERS=$(( ( ( JEST_TESTS_LENGTH + RUNNER_CHUNK_SIZE - 1 ) / RUNNER_CHUNK_SIZE ) > 0 ? ( ( JEST_TESTS_LENGTH + RUNNER_CHUNK_SIZE - 1 ) / RUNNER_CHUNK_SIZE ) : 1 )) INDEX_ARRAY=$(seq 0 $(( RUNNERS - 1 )) | jq -s .) echo "jest_test_matrix=$(jq -nc --argjson index "$INDEX_ARRAY" --argjson total "$RUNNERS" '{index: $index, total: [$total]}')" >> $GITHUB_OUTPUT @@ -144,15 +142,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' @@ -233,19 +223,18 @@ jobs: GITHUB_PR_SHA: ${{ github.event.pull_request.head.sha || github.sha }} GITHUB_PR_REF: ${{ github.event.pull_request.head.ref || github.ref }} + JEST_TESTS: ${{ needs.files-changed.outputs.jest_test_files }} + # Disable testing-library from printing out any of of the DOM to # stdout. No one actually looks through this in CI, they're just # going to run it locally. # # This quiets up the logs quite a bit. DEBUG_PRINT_LIMIT: 0 + # When the "Frontend: Rerun Flaky Tests" label is on the PR, # tests wrapped with it.isKnownFlake() run 50x to validate fixes. RERUN_KNOWN_FLAKY_TESTS: "${{ contains(github.event.pull_request.labels.*.name, 'Frontend: Rerun Flaky Tests') }}" - # When set, Jest uses --changedSince to run only tests affected by this PR. - # Empty on master or when non-frontend files changed (falls back to full suite). - MERGE_BASE: ${{ needs.files-changed.outputs.merge_base }} - MERGE_BASE_STRATEGY: ${{ needs.files-changed.outputs.merge_base_strategy }} run: pnpm run test-ci --forceExit form-field-registry: 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/bin/send-cell-test-event.py b/bin/send-cell-test-event.py new file mode 100755 index 00000000000000..844a8e8b80dc75 --- /dev/null +++ b/bin/send-cell-test-event.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +"""Send a test event through the cell-routing edge relay using the Sentry SDK. + +Pair with `devservices up --mode cell-routing`. Resolves a project key from the +local dev database (the internal project, id 1, which every dev install +bootstraps — override with PROJECT_ID), points a DSN at the edge relay on :7901, +and captures an event with sentry_sdk. +The edge reads the advertised upstream from its project config and forwards the +envelope there (relay-cell), which processes it to Kafka -> Sentry. + +Unlike the curl helper, this exercises the real SDK envelope path +(/api//envelope/), which is closer to how an actual client talks to Relay. + +Usage (with the dev env active): + bin/send-cell-test-event.py + PROJECT_ID=42 bin/send-cell-test-event.py + TARGET=127.0.0.1:7900 bin/send-cell-test-event.py # override: e.g. straight to relay-cell +""" + +# CLI helper: printing to the terminal is the whole point. `print` is flagged by +# both linters under different codes, each needing its own file-level directive. +# ruff: noqa: T201 +# flake8: noqa: S002 + +from __future__ import annotations + +import os + +from sentry.runner import configure + +configure() + +from sentry.models.project import Project # noqa: E402 +from sentry.models.projectkey import ProjectKey # noqa: E402 + +# Defaults to the edge relay's address from devservices/config.yml (cell-routing +# mode). Override TARGET to send elsewhere, e.g. straight to relay-cell on :7900. +target = os.environ.get("TARGET", "127.0.0.1:7901") +# The internal project (id 1) is bootstrapped for every dev install. +project_id = os.environ.get("PROJECT_ID", "1") +project = Project.objects.filter(id=project_id).first() +key = ProjectKey.get_default(project) if project else None +if key is None: + raise SystemExit( + f"no active store key found for project {project_id} in dev db " + "(is the devserver bootstrapped? try PROJECT_ID=)" + ) + +# Point the DSN host at the edge relay rather than at Sentry directly. +dsn = f"http://{key.public_key}@{target}/{key.project_id}" +print(f"Using DSN {dsn}") +print("(watch the edge route it: docker logs -f sentry-relay-edge-1)") + +import sentry_sdk # noqa: E402 + +# Send via an isolated scope + client so we don't replace Sentry's own global SDK. +client = sentry_sdk.Client(dsn=dsn, default_integrations=False, traces_sample_rate=0) +with sentry_sdk.new_scope() as scope: + scope.set_client(client) + event_id = scope.capture_message("cell-routing test", level="error") +client.flush(timeout=5) + +print(f"sent event {event_id} via {target}") diff --git a/devservices/config.yml b/devservices/config.yml index 176478efee9dd4..e6d211ff6783de 100644 --- a/devservices/config.yml +++ b/devservices/config.yml @@ -100,6 +100,13 @@ x-sentry-service-config: description: Memcached used for caching spotlight: description: Spotlight server for local debugging + # Local Relays used only by the `cell-routing` mode to exercise Relay's + # advertised-upstream routing without touching the relay repo or the default + # `relay` config. relay-cell replaces the plain `relay` in this mode. + relay-cell: + description: Processing Relay terminus that advertises an upstream (cell-routing only) + relay-edge: + description: Downstream Relay that routes on the advertised upstream (cell-routing only) objectstore: description: Storage for files and blobs remote: @@ -294,7 +301,22 @@ x-sentry-service-config: post-process-forwarder-errors, ] minimal: [postgres, snuba] - cell-routing: [postgres, snuba, relay, spotlight, objectstore, synapse] + cell-routing: + [ + postgres, + snuba, + redis, + taskbroker, + spotlight, + objectstore, + synapse, + taskworker, + taskworker-scheduler, + ingest-events, + post-process-forwarder-errors, + relay-cell, + relay-edge, + ] # The minimal set of services Symbolicator needs to run # Sentry integration tests. symbolicator-tests: [postgres, snuba, objectstore] @@ -491,6 +513,53 @@ services: - devservices labels: - orchestrator=devservices + # Relay chain for the `cell-routing` mode. These exercise Relay's + # advertised-upstream routing entirely from sentry's devservices: the + # `advertised_upstream` value lives in relay-cell.yml (mounted only here), so + # the default `relay` service and its config are untouched. relay-cell is the + # processing terminus (replaces plain `relay` here); relay-edge forwards. + relay-cell: + image: ghcr.io/getsentry/relay:nightly + command: [run, --config, /etc/relay] + healthcheck: + test: + ['CMD', '/bin/relay', '--config', '/etc/relay', 'healthcheck', '--mode', 'live'] + interval: 5s + timeout: 5s + retries: 3 + ports: + - 127.0.0.1:7900:7900 + volumes: + - ./config/relay-cell.yml:/etc/relay/config.yml + - ./config/relay-cell-credentials.json:/etc/relay/credentials.json + extra_hosts: + - host.docker.internal:host-gateway + networks: + - devservices + labels: + - orchestrator=devservices + restart: unless-stopped + relay-edge: + image: ghcr.io/getsentry/relay:nightly + command: [run, --config, /etc/relay] + healthcheck: + test: + ['CMD', '/bin/relay', '--config', '/etc/relay', 'healthcheck', '--mode', 'live'] + interval: 5s + timeout: 5s + retries: 3 + ports: + - 127.0.0.1:7901:7901 + volumes: + - ./config/relay-edge.yml:/etc/relay/config.yml + - ./config/relay-edge-credentials.json:/etc/relay/credentials.json + extra_hosts: + - host.docker.internal:host-gateway + networks: + - devservices + labels: + - orchestrator=devservices + restart: unless-stopped # Taskbroker in passthrough mode for ingest-profiles topic (STREAM-1041). # This is a local service rather than a remote dependency because devservices # doesn't support passing custom environment variables to remote services (DI-1956). diff --git a/devservices/config/relay-cell-credentials.json b/devservices/config/relay-cell-credentials.json new file mode 100644 index 00000000000000..dd442340fb1330 --- /dev/null +++ b/devservices/config/relay-cell-credentials.json @@ -0,0 +1,5 @@ +{ + "secret_key": "YUxl8BCg4kay_7qpDGm6l2p0roa7Pxuk2cRm1qeQrTY", + "public_key": "8lXBuHpE-dFoTblfKkp9BqjIZl33dyaGhmyjuZLB6aY", + "id": "01ff20ff-95f8-49db-b3f8-71f0caaa054d" +} diff --git a/devservices/config/relay-cell.yml b/devservices/config/relay-cell.yml new file mode 100644 index 00000000000000..f96e07beee4776 --- /dev/null +++ b/devservices/config/relay-cell.yml @@ -0,0 +1,35 @@ +--- +# Processing Relay terminus for the `cell-routing` dev mode. This is the +# cell-routing replacement for the default `relay` service: same role (the +# processing relay next to Sentry, writing to Kafka) plus an advertised upstream. +# +# Chain: event -> relay-edge -> relay-cell -> Kafka -> Sentry +# +# `advertised_upstream` is the upstream relay-cell tells the downstream relay-edge +# to forward each project's envelopes/metrics to. In single-cell dev the only +# cell is relay-cell itself, so it points back here via the host-published port +# (a distinct address from relay-edge's default upstream), which keeps ingestion +# working end-to-end while making the advertised-upstream override observable in +# relay-edge's logs. +# +# This file is only mounted by the `cell-routing` mode, so `advertised_upstream` +# never affects default dev. +relay: + upstream: 'http://host.docker.internal:8001/' + advertised_upstream: 'http://host.docker.internal:7900/' + host: 0.0.0.0 + port: 7900 +logging: + # Verbose so you can see the forwarded envelope arrive and get produced to Kafka. + # Drop back to INFO once routing is validated. + level: trace + enable_backtraces: false +limits: + shutdown_timeout: 0 +processing: + enabled: true + kafka_config: + - {name: 'bootstrap.servers', value: 'kafka:9093'} + # Align with Relay's default `max_envelope_size` (200 MiB -> 209,715,200 bytes). + - {name: 'message.max.bytes', value: 209715200} + redis: redis://redis:6379 diff --git a/devservices/config/relay-edge-credentials.json b/devservices/config/relay-edge-credentials.json new file mode 100644 index 00000000000000..42907b1b218c43 --- /dev/null +++ b/devservices/config/relay-edge-credentials.json @@ -0,0 +1,5 @@ +{ + "secret_key": "IaxE7cSgxBhE9Gtr11Y8iaWaXLBT4-_1r7z-j_stxE0", + "public_key": "NxO7wFbVRLhTgV6T4pSpQoOdplYLipExVQyNhdqM0lE", + "id": "7fb4f675-d94c-4e3e-b76b-cb724e7670f3" +} diff --git a/devservices/config/relay-edge.yml b/devservices/config/relay-edge.yml new file mode 100644 index 00000000000000..366deca57023e3 --- /dev/null +++ b/devservices/config/relay-edge.yml @@ -0,0 +1,24 @@ +--- +# Downstream "edge" Relay for the `cell-routing` dev mode. It forwards traffic +# rather than processing it (no Kafka/Redis needed). Send test events here. +# +# Register + project config: relay-edge -> synapse-ingest-router -> relay-cell (+ Sentry) +# Envelopes (once advertised): relay-edge -> relay-cell directly (bypasses synapse) +# +# relay-edge registers and fetches project configs through the synapse +# ingest-router (its `upstream`). The ingest-router passes relay-cell's config +# through unchanged, including the top-level `upstream` field relay-cell injects +# from its `advertised_upstream`. relay-edge then sends envelopes/metrics +# directly to that advertised upstream (relay-cell), bypassing synapse and +# reusing its existing relay credentials. +relay: + upstream: 'http://synapse-ingest-router:3000/' + host: 0.0.0.0 + port: 7901 +logging: + # Verbose so the upstream forwarding (to the advertised upstream) is visible. + # Drop back to INFO once routing is validated. + level: trace + enable_backtraces: false +limits: + shutdown_timeout: 0 diff --git a/jest.config.ts b/jest.config.ts index a8fc9e61396882..044a76b46f0e97 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,4 +1,3 @@ -import {execFileSync} from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; @@ -32,64 +31,31 @@ const swcConfig: SwcOptions = { const { CI, + JEST_TEST_BALANCER, CI_NODE_TOTAL, CI_NODE_INDEX, GITHUB_PR_SHA, GITHUB_PR_REF, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, - MERGE_BASE, - MERGE_BASE_STRATEGY, } = process.env; +const JEST_TESTS = process.env.JEST_TESTS + ? (JSON.parse(process.env.JEST_TESTS || 'undefined') as string[] | undefined) + : undefined; const IS_MASTER_BRANCH = GITHUB_PR_REF === 'refs/heads/master'; const optionalTags: { balancer?: boolean; balancer_strategy?: string; - merge_base: string; - merge_base_strategy: string; } = { balancer: false, - merge_base: MERGE_BASE || '', - merge_base_strategy: MERGE_BASE_STRATEGY || 'full', }; -let JEST_TESTS: string[] | undefined; - -// prevents forkbomb as we don't want jest --listTests --json -// to reexec itself here -if (CI && !process.env.JEST_LIST_TESTS_INNER) { - try { - const listTestArguments = ['exec', 'jest', '--listTests', '--json']; - - if (MERGE_BASE) { - console.log('MERGE_BASE detected:', MERGE_BASE); - listTestArguments.push('--changedSince', MERGE_BASE, '--passWithNoTests'); - } - - const stdout = execFileSync('pnpm', listTestArguments, { - stdio: 'pipe', - encoding: 'utf-8', - env: {...process.env, JEST_LIST_TESTS_INNER: '1'}, - }); - JEST_TESTS = JSON.parse(stdout); - } catch (err: any) { - if (err.code) { - throw new Error(`err code ${err.code} when spawning process`); - } else { - const {stdout, stderr} = err; - throw new Error(` -error listing jest tests - -stdout: -${stdout} - -stderr: -${stderr} -`); - } - } +if (!!JEST_TEST_BALANCER && !CI) { + throw new Error( + '[Operation only allowed in CI]: Jest test balancer should never be ran manually as you risk skewing the numbers - please trigger the automated github workflow at https://github.com/getsentry/sentry/actions/workflows/jest-balance.yml' + ); } /** @@ -98,8 +64,10 @@ ${stderr} * `JEST_TESTS` is a list of all tests that will run, captured by `jest --listTests --json` * Then we split up the tests based on the total number of CI instances that will * be running the tests. + * + * By default we'll run everything we were given. */ -let testMatch: string[] | undefined; +let testMatch = JEST_TESTS; function getTestsForGroup( nodeIndex: number, @@ -306,7 +274,7 @@ const config: Config.InitialOptions = { // window/cookies state. '@sentry/toolbar': '/tests/js/sentry-test/mocks/sentryToolbarMock.js', }, - passWithNoTests: !!MERGE_BASE, + passWithNoTests: Boolean(JEST_TESTS?.length), setupFiles: [ '/static/app/utils/silence-react-unsafe-warnings.ts', 'jest-canvas-mock', @@ -335,6 +303,9 @@ const config: Config.InitialOptions = { moduleFileExtensions: ['js', 'ts', 'jsx', 'tsx', 'pegjs'], globals: {}, + testResultsProcessor: JEST_TEST_BALANCER + ? '/tests/js/test-balancer/index.js' + : undefined, reporters: ['default'], /** * jest.clearAllMocks() automatically called before each test 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/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/pyproject.toml b/pyproject.toml index 850f2518d0d2d5..9b407dc51d8197 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", @@ -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/setup.cfg b/setup.cfg index 9c0c2dcb646582..b23add74004891 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = sentry -version = 26.6.0.dev0 +version = 26.7.0.dev0 description = A realtime logging and aggregation server. long_description = file: README.md long_description_content_type = text/markdown 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/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/api/endpoints/event_attachments.py b/src/sentry/api/endpoints/event_attachments.py index 9d206ffff8a156..0df10eb6e5fb92 100644 --- a/src/sentry/api/endpoints/event_attachments.py +++ b/src/sentry/api/endpoints/event_attachments.py @@ -57,7 +57,9 @@ class EventAttachmentsEndpoint(ProjectEndpoint): }, examples=EventAttachmentExamples.LIST_EVENT_ATTACHMENTS, ) - def get(self, request: Request, project, event_id) -> Response: + def get( + self, request: Request, project, event_id + ) -> Response[list[EventAttachmentSerializerResponse]]: """ Retrieve a list of attachments uploaded for a given event. 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): diff --git a/src/sentry/api/endpoints/organization_events_timeseries.py b/src/sentry/api/endpoints/organization_events_timeseries.py index 4e64cdcb70494f..33cdf500675243 100644 --- a/src/sentry/api/endpoints/organization_events_timeseries.py +++ b/src/sentry/api/endpoints/organization_events_timeseries.py @@ -158,7 +158,7 @@ def get_comparison_delta(self, request: Request) -> timedelta | None: }, examples=DiscoverAndPerformanceExamples.QUERY_TIMESERIES, ) - def get(self, request: Request, organization: Organization) -> Response: + def get(self, request: Request, organization: Organization) -> Response[StatsResponse]: """ Retrieves explore data for a given organization as a timeseries. diff --git a/src/sentry/api/endpoints/organization_releases.py b/src/sentry/api/endpoints/organization_releases.py index 0af74a8a6fa609..ebe124bb305687 100644 --- a/src/sentry/api/endpoints/organization_releases.py +++ b/src/sentry/api/endpoints/organization_releases.py @@ -2,6 +2,7 @@ import re from datetime import datetime, timedelta +from typing import TypedDict import sentry_sdk from django.db import IntegrityError @@ -12,8 +13,9 @@ 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_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import ReleaseAnalyticsMixin, cell_silo_endpoint from sentry.api.bases import NoProjects @@ -38,8 +40,18 @@ ReleaseHeadCommitSerializerDeprecated, ReleaseWithVersionSerializer, ) +from sentry.api.serializers.types import ReleaseSerializerResponse from sentry.api.utils import get_auth_api_token_type -from sentry.apidocs.parameters import CursorQueryParam +from sentry.apidocs.constants import ( + RESPONSE_ALREADY_REPORTED, + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.examples.release_examples import ReleaseExamples +from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, ReleaseParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.exceptions import InvalidSearchQuery from sentry.models.activity import Activity from sentry.models.organization import Organization @@ -288,11 +300,13 @@ def debounce_update_release_health_data(organization, project_ids: list[int]): cache.set_many(dict(zip(should_update.values(), [True] * len(should_update))), 60) +@extend_schema(tags=["Releases"]) @cell_silo_endpoint class OrganizationReleasesEndpoint(OrganizationReleasesBaseEndpoint, ReleaseAnalyticsMixin): + owner = ApiOwner.TELEMETRY_EXPERIENCE publish_status = { - "GET": ApiPublishStatus.UNKNOWN, - "POST": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.PRIVATE, } rate_limits = RateLimitConfig( @@ -332,9 +346,26 @@ def get_projects(self, request: Request, organization, project_ids=None, project @extend_schema( operation_id="List an Organization's Releases", - parameters=[CursorQueryParam], + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.ENVIRONMENT, + ReleaseParams.QUERY, + CursorQueryParam, + ], + responses={ + 200: inline_sentry_response_serializer( + "ListOrganizationReleasesResponse", list[ReleaseSerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=ReleaseExamples.LIST_ORGANIZATION_RELEASES, ) def get(self, request: Request, organization: Organization) -> Response: + """ + Return a list of releases for a given organization, sorted by most recent. + """ if request.headers.get("X-Performance-Optimizations") == "enabled": return self.__get_new(request, organization) else: @@ -408,17 +439,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 +614,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 @@ -698,48 +714,29 @@ def qs_load_func(queryset, total_offset, qs_offset, limit): **paginator_kwargs, ) + @extend_schema( + operation_id="Create a New Release for an Organization", + parameters=[GlobalParams.ORG_ID_OR_SLUG], + request=ReleaseSerializerWithProjects, + responses={ + 201: inline_sentry_response_serializer( + "CreateOrganizationReleaseResponse", ReleaseSerializerResponse + ), + 208: RESPONSE_ALREADY_REPORTED, + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + }, + examples=ReleaseExamples.CREATE_RELEASE, + ) def post(self, request: Request, organization: Organization) -> Response: """ - Create a New Release for an Organization - ```````````````````````````````````````` - Create a new release for the given Organization. Releases are used by - Sentry to improve its error reporting abilities by correlating - first seen events with the release that might have introduced the - problem. - Releases are also necessary for sourcemaps and other debug features - that require manual upload for functioning well. - - :pparam string organization_id_or_slug: the id or slug of the organization the - release belongs to. - :param string version: a version identifier for this release. Can - be a version number, a commit hash etc. It cannot contain certain - whitespace characters (`\\r`, `\\n`, `\\f`, `\\x0c`, `\\t`) or any - slashes (`\\`, `/`). The version names `.`, `..` and `latest` are also - reserved, and cannot be used. - :param string ref: an optional commit reference. This is useful if - a tagged version has been provided. - :param url url: a URL that points to the release. This can be the - path to an online interface to the sourcecode - for instance. - :param array projects: a list of project ids or slugs that are involved in - this release - :param datetime dateReleased: an optional date that indicates when - the release went live. If not provided - the current time is assumed. - :param array commits: an optional list of commit data to be associated - with the release. Commits must include parameters - ``id`` (the sha of the commit), and can optionally - include ``repository``, ``message``, ``patch_set``, - ``author_name``, ``author_email``, and ``timestamp``. - See [release without integration example](/workflow/releases/). - :param array refs: an optional way to indicate the start and end commits - for each repository included in a release. Head commits - must include parameters ``repository`` and ``commit`` - (the HEAD sha). They can optionally include ``previousCommit`` - (the sha of the HEAD of the previous release), which should - be specified if this is the first time you've sent commit data. - ``commit`` may contain a range in the form of ``previousCommit..commit`` - :auth: required + Create a new release for the given organization. Releases are used by Sentry to + improve error reporting by correlating first-seen events with the release that may + have introduced them, and are required for source maps and other debug features. + + Release versions that are the same across multiple projects within an organization + are treated as the same release in Sentry. """ bind_organization_context(organization) serializer = ReleaseSerializerWithProjects( @@ -902,19 +899,46 @@ def post(self, request: Request, organization: Organization) -> Response: return Response(serializer.errors, status=400) +class OrganizationReleaseTimeseriesData(TypedDict): + version: str + date: datetime + + +@extend_schema(tags=["Releases"]) @cell_silo_endpoint class OrganizationReleasesStatsEndpoint(OrganizationReleasesBaseEndpoint): + owner = ApiOwner.TELEMETRY_EXPERIENCE publish_status = { - "GET": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, } + @extend_schema( + operation_id="List an Organization's Release Timeseries Data", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.ENVIRONMENT, + GlobalParams.STATS_PERIOD, + GlobalParams.START, + GlobalParams.END, + ReleaseParams.QUERY, + CursorQueryParam, + ], + responses={ + 200: inline_sentry_response_serializer( + "OrganizationReleaseTimeseriesResponse", + list[OrganizationReleaseTimeseriesData], + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=ReleaseExamples.LIST_RELEASE_TIMESERIES, + ) def get(self, request: Request, organization: Organization) -> Response: """ - List an Organization's Releases specifically for building timeseries - ``````````````````````````````` - Return a list of releases for a given organization, sorted for most recent releases. - - :pparam string organization_id_or_slug: the id or slug of the organization + Return a minimal list of an organization's releases (version and date only), + sorted by most recent. Intended for building release timeseries, such as + plotting release markers on charts. """ query = request.GET.get("query") diff --git a/src/sentry/api/endpoints/organization_trace_item_attributes.py b/src/sentry/api/endpoints/organization_trace_item_attributes.py index 08ce0cafb2129e..044c3198459ecb 100644 --- a/src/sentry/api/endpoints/organization_trace_item_attributes.py +++ b/src/sentry/api/endpoints/organization_trace_item_attributes.py @@ -47,6 +47,7 @@ from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED from sentry.apidocs.examples.trace_item_attribute_examples import TraceItemAttributeExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff from sentry.auth.superuser import is_active_superuser @@ -362,7 +363,9 @@ class OrganizationTraceItemAttributesEndpoint(OrganizationTraceItemAttributesEnd }, examples=TraceItemAttributeExamples.LIST_TRACE_ITEM_ATTRIBUTES, ) - def get(self, request: Request, organization: Organization) -> Response: + def get( + self, request: Request, organization: Organization + ) -> Response[list[TraceItemAttributeKey]] | Response[ValidationErrorResponse]: """ List the attribute keys available on a given trace item dataset (spans, logs, trace metrics, etc.), with optional substring and structured filtering. @@ -372,7 +375,7 @@ def get(self, request: Request, organization: Organization) -> Response: serializer = OrganizationTraceItemAttributesEndpointSerializer(data=request.GET) if not serializer.is_valid(): - return Response(serializer.errors, status=400) + return Response(as_validation_errors(serializer), status=400) try: snuba_params = self.get_snuba_params(request, organization) diff --git a/src/sentry/api/endpoints/project_filters.py b/src/sentry/api/endpoints/project_filters.py index 4222aa0d1cd821..197613aaa4c361 100644 --- a/src/sentry/api/endpoints/project_filters.py +++ b/src/sentry/api/endpoints/project_filters.py @@ -42,12 +42,12 @@ class ProjectFiltersEndpoint(ProjectEndpoint): }, examples=ProjectExamples.GET_PROJECT_FILTERS, ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project) -> Response[list[ProjectFilterResponse]]: """ Retrieve a list of filters for a given project. `active` will be either a boolean or a list for the legacy browser filters. """ - results = [] + results: list[ProjectFilterResponse] = [] for flt in inbound_filters.get_all_filter_specs(): results.append( { 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/api/endpoints/source_map_debug.py b/src/sentry/api/endpoints/source_map_debug.py index 3145657c0976b2..af9456d0c93889 100644 --- a/src/sentry/api/endpoints/source_map_debug.py +++ b/src/sentry/api/endpoints/source_map_debug.py @@ -150,7 +150,9 @@ class SourceMapDebugEndpoint(ProjectEndpoint): }, examples=SourceMapDebugExamples.GET_SOURCE_MAP_DEBUG, ) - def get(self, request: Request, project: Project, event_id: str) -> Response: + def get( + self, request: Request, project: Project, event_id: str + ) -> Response[SourceMapDebugResponse]: """ Return a list of source map errors for a given event. """ @@ -240,11 +242,11 @@ def get(self, request: Request, project: Project, event_id: str) -> Response: scraping_attempt_map = get_scraping_attempt_map(event_data) # build information about individual exceptions and their stack traces - processed_exceptions = [] + processed_exceptions: list[SourceMapDebugException] = [] exception_values = get_path(event_data, "exception", "values") if exception_values is not None: for exception_value in exception_values: - processed_frames = [] + processed_frames: list[SourceMapDebugFrame] = [] frames = get_path(exception_value, "raw_stacktrace", "frames") stacktrace_frames = get_path(exception_value, "stacktrace", "frames") if frames is not None: diff --git a/src/sentry/api/endpoints/timeseries.py b/src/sentry/api/endpoints/timeseries.py index 20aeae732171f6..e831519e73be0a 100644 --- a/src/sentry/api/endpoints/timeseries.py +++ b/src/sentry/api/endpoints/timeseries.py @@ -1,4 +1,4 @@ -from typing import Any, Literal, NotRequired, TypedDict +from typing import Literal, NotRequired, TypedDict # Assumed ingestion delay for timeseries, this is a static number for now just to match how the frontend was doing it INGESTION_DELAY = 90 @@ -48,6 +48,6 @@ class StatsResponse(TypedDict): timeSeries: list[TimeSeries] -EMPTY_STATS_RESPONSE: dict[str, Any] = { +EMPTY_STATS_RESPONSE: StatsResponse = { "timeSeries": [], } diff --git a/src/sentry/api/fields/user.py b/src/sentry/api/fields/user.py index 6ce4150d4ddac9..2c8f8746319056 100644 --- a/src/sentry/api/fields/user.py +++ b/src/sentry/api/fields/user.py @@ -2,6 +2,8 @@ from typing import Any +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from sentry.users.models.user import User @@ -9,6 +11,7 @@ from sentry.users.services.user.service import user_service +@extend_schema_field(field=OpenApiTypes.STR) class UserField(serializers.Field): def to_representation(self, value: RpcUser) -> str: return value.username 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/api/serializers/rest_framework/release.py b/src/sentry/api/serializers/rest_framework/release.py index ee8599856721a1..7d561b02e40fe4 100644 --- a/src/sentry/api/serializers/rest_framework/release.py +++ b/src/sentry/api/serializers/rest_framework/release.py @@ -76,7 +76,11 @@ class ReleaseSerializer(serializers.Serializer): help_text="An optional list of commit data to be associated.", ) - status = serializers.CharField(required=False, allow_null=False) + status = serializers.CharField( + required=False, + allow_null=False, + help_text="The status of the release. Can be `open` or `archived`.", + ) def validate_status(self, value): try: @@ -87,9 +91,15 @@ def validate_status(self, value): class ReleaseWithVersionSerializer(ReleaseSerializer): version = serializers.CharField( - max_length=MAX_VERSION_LENGTH, trim_whitespace=False, required=True + max_length=MAX_VERSION_LENGTH, + trim_whitespace=False, + required=True, + help_text="A version identifier for this release. Can be a version number, a commit hash, and so on.", + ) + owner = UserField( + required=False, + help_text="The username of the user to set as the release owner.", ) - owner = UserField(required=False) def validate_version(self, value): if not Release.is_valid_version(value): diff --git a/src/sentry/api/serializers/types.py b/src/sentry/api/serializers/types.py index d8bd2fc70c2a51..988304d79250cb 100644 --- a/src/sentry/api/serializers/types.py +++ b/src/sentry/api/serializers/types.py @@ -22,6 +22,11 @@ class ReleaseSerializerResponseOptional(TypedDict, total=False): class ReleaseSerializerResponse(ReleaseSerializerResponseOptional): + # NOTE: The API design guidelines (https://develop.sentry.dev/backend/api/design/) + # call for resource identifiers to be returned as strings. This release `id` is a + # long-standing integer in the public response and is relied on by existing clients, + # so changing it would be breaking; left as-is intentionally. `version` is the + # human-friendly identifier. Do not copy this pattern into new public responses. id: int version: str newGroups: int 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) 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/api_publish_status_allowlist_dont_modify.py b/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py index cec095e86eb74e..1a1c6b128f5577 100644 --- a/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py +++ b/src/sentry/apidocs/api_publish_status_allowlist_dont_modify.py @@ -5,24 +5,11 @@ """ API_PUBLISH_STATUS_ALLOWLIST_DONT_MODIFY = { - "/api/0/{var}/{issue_id}/stats/": {"GET"}, - "/api/0/{var}/{issue_id}/tags/": {"GET"}, - "/api/0/{var}/{issue_id}/integrations/{integration_id}/": {"DELETE", "GET", "POST", "PUT"}, - "/api/0/organizations/{organization_id_or_slug}/{var}/{issue_id}/stats/": {"GET"}, - "/api/0/organizations/{organization_id_or_slug}/{var}/{issue_id}/tags/": {"GET"}, - "/api/0/organizations/{organization_id_or_slug}/{var}/{issue_id}/integrations/{integration_id}/": { - "DELETE", - "GET", - "POST", - "PUT", - }, "/api/0/organizations/{organization_id_or_slug}/integrations/{integration_id}/serverless-functions/": { "GET", "POST", }, "/api/0/organizations/{organization_id_or_slug}/invite-requests/": {"GET", "POST"}, - "/api/0/organizations/{organization_id_or_slug}/releases/": {"GET", "POST"}, - "/api/0/organizations/{organization_id_or_slug}/releases/stats/": {"GET"}, "/api/0/organizations/{organization_id_or_slug}/releases/{version}/assemble/": {"POST"}, "/api/0/organizations/{organization_id_or_slug}/releases/{version}/files/": {"GET", "POST"}, "/api/0/organizations/{organization_id_or_slug}/releases/{version}/files/{file_id}/": { @@ -30,26 +17,12 @@ "GET", "PUT", }, - "/api/0/organizations/{organization_id_or_slug}/releases/{version}/commits/": {"GET"}, "/api/0/organizations/{organization_id_or_slug}/sentry-app-installations/": {"GET"}, "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/hooks/": {"GET", "POST"}, "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/hooks/{hook_id}/stats/": { "GET" }, - "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/": {"GET", "POST"}, - "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/token/": { - "GET", - "POST", - }, "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/completion/": {"GET"}, - "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/{version}/": { - "DELETE", - "GET", - "PUT", - }, - "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/{version}/commits/": { - "GET" - }, "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/releases/{version}/repositories/": { "GET" }, @@ -78,5 +51,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"}, } diff --git a/src/sentry/apidocs/examples/release_examples.py b/src/sentry/apidocs/examples/release_examples.py index d91f3bf3b6fb27..25e34885b53165 100644 --- a/src/sentry/apidocs/examples/release_examples.py +++ b/src/sentry/apidocs/examples/release_examples.py @@ -2,6 +2,7 @@ from drf_spectacular.utils import OpenApiExample +from sentry.api.serializers.models.commit import CommitSerializerResponse from sentry.api.serializers.types import ReleaseSerializerResponse RELEASE: ReleaseSerializerResponse = { @@ -53,6 +54,20 @@ } +COMMIT: CommitSerializerResponse = { + "id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "message": "fix: handle empty release version", + "dateCreated": datetime.fromisoformat("2024-01-01T00:00:00Z"), + "pullRequest": None, + "suspectCommitType": "", +} + +RELEASE_TIMESERIES = { + "version": "frontend@1.0.0", + "date": datetime.fromisoformat("2024-01-01T00:00:00Z"), +} + + class ReleaseExamples: LIST_PROJECT_RELEASES = [ OpenApiExample( @@ -62,3 +77,48 @@ class ReleaseExamples: status_codes=["200"], ) ] + + LIST_ORGANIZATION_RELEASES = [ + OpenApiExample( + "Return a list of releases for an organization", + value=[RELEASE], + response_only=True, + status_codes=["200"], + ) + ] + + RETRIEVE_RELEASE = [ + OpenApiExample( + "Retrieve a release", + value=RELEASE, + response_only=True, + status_codes=["200"], + ) + ] + + CREATE_RELEASE = [ + OpenApiExample( + "Create a release", + value=RELEASE, + response_only=True, + status_codes=["201"], + ) + ] + + LIST_RELEASE_COMMITS = [ + OpenApiExample( + "Return a list of commits for a release", + value=[COMMIT], + response_only=True, + status_codes=["200"], + ) + ] + + LIST_RELEASE_TIMESERIES = [ + OpenApiExample( + "Return a list of release versions and dates", + value=[RELEASE_TIMESERIES], + response_only=True, + status_codes=["200"], + ) + ] diff --git a/src/sentry/apidocs/response_types.py b/src/sentry/apidocs/response_types.py new file mode 100644 index 00000000000000..48736e24f6f6d7 --- /dev/null +++ b/src/sentry/apidocs/response_types.py @@ -0,0 +1,75 @@ +"""Shared response shapes and helpers for endpoint Response annotations. + +This module holds TypedDicts, type aliases, and small helpers that recur +across multiple endpoints in the Response[T] typing rollout. It is *not* +authoritative — endpoints whose error/response shapes don't match anything +here are expected to declare local types in their own files. The structural +linter at `sentry.apidocs._check_response_annotation_matches_schema` is +name-agnostic; it does not special-case any 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. + +`ValidationErrorResponse` + `as_validation_errors()` cover the parallel +case for DRF `Response(serializer.errors, status=400)` paths. + +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 Any, TypeAlias, TypedDict + +from rest_framework import serializers + + +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 + + +ValidationErrorResponse: TypeAlias = dict[str, Any] +"""DRF's validation-error body shape: `{field_name: , ...}`. + +DRF emits a few different value shapes here depending on the serializer: +- Flat field errors: `{"field": ["error msg", ...]}` +- Nested (e.g. `Serializer` with a nested `Serializer` field): + `{"field": {"nested_field": ["error msg", ...]}}` +- Non-field errors (raised in `validate()`): + `{"non_field_errors": ["error msg", ...]}` + +The alias is intentionally `dict[str, Any]` — narrower types like +`dict[str, list[str]]` collapse the nested-dict case and lose the error +messages at runtime. The runtime value of `serializer.errors` is a +`ReturnDict[Any, Any]` that mypy can't structurally match against any +typed `Response[T]` union arm, so use this alias as the union arm: + + def post(...) -> Response[FooResponse] | Response[ValidationErrorResponse]: + +and produce the body via `as_validation_errors(serializer)` below. +""" + + +def as_validation_errors( + serializer: serializers.Serializer[Any], +) -> ValidationErrorResponse: + """Project a DRF `Serializer.errors` ReturnDict into a structurally typed + `dict[str, Any]` so a `Response[ValidationErrorResponse]` union arm is + satisfied without `cast()`. The DRF error structure (flat or nested) is + preserved verbatim — only the static type is narrowed. + + Use immediately after `not serializer.is_valid()`: + + if not serializer.is_valid(): + return Response(as_validation_errors(serializer), status=400) + """ + return dict(serializer.errors) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index eeff588061f873..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 @@ -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/discover/endpoints/discover_saved_queries.py b/src/sentry/discover/endpoints/discover_saved_queries.py index 12665332deffae..3c3d31ffe40818 100644 --- a/src/sentry/discover/endpoints/discover_saved_queries.py +++ b/src/sentry/discover/endpoints/discover_saved_queries.py @@ -69,7 +69,9 @@ def has_feature(self, organization, request): }, examples=DiscoverExamples.DISCOVER_SAVED_QUERIES_QUERY_RESPONSE, ) - def get(self, request: Request, organization: Organization) -> Response: + def get( + self, request: Request, organization: Organization + ) -> Response[list[DiscoverSavedQueryResponse]]: """ Retrieve a list of saved queries that are associated with the given organization. """ @@ -141,7 +143,10 @@ def get(self, request: Request, organization: Organization) -> Response: # Old discover expects all queries and uses this parameter. if request.query_params.get("all") == "1": saved_queries = list(queryset.all()) - return Response(serialize(saved_queries), status=200) + return Response( + serialize(saved_queries, serializer=DiscoverSavedQueryModelSerializer()), + status=200, + ) def data_fn(offset, limit): return list(queryset[offset : offset + limit]) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 70433181c94d04..bafdd973b83e63 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 @@ -128,7 +126,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-claude-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - manager.add("organizations:github-repo-auto-sync-webhook", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable GitHub Enterprise to accept github.com as a valid Installation URL manager.add("organizations:github-enterprise-github-com-source", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) @@ -150,6 +147,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:legacy-webhook-disable-old-path", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:legacy-webhook-dry-run", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:legacy-webhook-new-path", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + manager.add("organizations:legacy-webhook-ui", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable flamegraph visualization for MetricKit hang diagnostic stack traces manager.add("organizations:metrickit-flamegraph", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enables higher limit for alert rules @@ -240,8 +238,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 @@ -280,12 +276,8 @@ 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) - # 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 @@ -472,8 +464,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/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: 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/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/integrations/data_forwarding/base.py b/src/sentry/integrations/data_forwarding/base.py index fc5c6bd425ce63..71304b6c2c7b6c 100644 --- a/src/sentry/integrations/data_forwarding/base.py +++ b/src/sentry/integrations/data_forwarding/base.py @@ -1,11 +1,11 @@ import logging -import random from abc import ABC, abstractmethod from typing import Any, ClassVar -from sentry import options, ratelimits +from sentry import ratelimits from sentry.integrations.models.data_forwarder_project import DataForwarderProject from sentry.integrations.types import DataForwarderProviderSlug +from sentry.options.rollout import in_random_rollout from sentry.services.eventstore.models import Event, GroupEvent from sentry.utils import metrics @@ -91,7 +91,7 @@ def post_process( return event_payload = self.get_event_payload(event=event, config=config) - if random.random() < options.get("data-forwarding.task-rollout-rate"): + if in_random_rollout("data-forwarding.task-rollout-rate"): task_payload = self.get_task_payload(event=event, config=config) forward_event.delay( data_forwarder_project_id=data_forwarder_project.id, @@ -103,3 +103,6 @@ def post_process( ) else: self.forward_event(event=event, payload=event_payload, config=config) + metrics.incr( + "data_forwarding.post_process.directly_forwarded", tags={"provider": self.provider} + ) diff --git a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py index b269538291edfe..4c6140376d06a1 100644 --- a/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py +++ b/src/sentry/integrations/github/tasks/sync_repos_on_install_change.py @@ -3,7 +3,6 @@ from taskbroker_client.retry import Retry -from sentry import features from sentry.constants import ObjectStatus from sentry.integrations.github.webhook_types import GitHubInstallationRepo from sentry.integrations.services.integration import integration_service @@ -84,9 +83,6 @@ def sync_repos_on_install_change( ) continue - if not features.has("organizations:github-repo-auto-sync-webhook", rpc_org): - continue - with SCMIntegrationInteractionEvent( interaction_type=SCMIntegrationInteractionType.SYNC_REPOS_ON_INSTALL_CHANGE, integration_id=integration_id, diff --git a/src/sentry/integrations/services/repository/impl.py b/src/sentry/integrations/services/repository/impl.py index db697fba069537..042e7bd55cceb4 100644 --- a/src/sentry/integrations/services/repository/impl.py +++ b/src/sentry/integrations/services/repository/impl.py @@ -298,10 +298,11 @@ def auto_link_repos_by_name( self, *, organization_id: int, - repo_ids: list[int], + repo_ids: list[int] | None = None, + project_ids: list[int] | None = None, ) -> int: try: organization = Organization.objects.get(id=organization_id) except Organization.DoesNotExist: return 0 - return auto_link_repos_by_name(organization, repo_ids) + return auto_link_repos_by_name(organization, repo_ids, project_ids=project_ids) diff --git a/src/sentry/integrations/services/repository/service.py b/src/sentry/integrations/services/repository/service.py index 5f51efd8d2487b..8888a6b28aca67 100644 --- a/src/sentry/integrations/services/repository/service.py +++ b/src/sentry/integrations/services/repository/service.py @@ -150,13 +150,18 @@ def auto_link_repos_by_name( self, *, organization_id: int, - repo_ids: list[int], + repo_ids: list[int] | None = None, + project_ids: list[int] | None = None, ) -> int: """ Auto-link repositories to projects based on name matching. Only creates links when neither the repo nor the project already has a ProjectRepository row. + If repo_ids is provided, only consider those repos. Otherwise all + unlinked repos in the org are considered. + If project_ids is provided, only consider those projects. + Returns the number of links created. """ diff --git a/src/sentry/integrations/source_code_management/auto_link_repos.py b/src/sentry/integrations/source_code_management/auto_link_repos.py index 3ef850605161df..b4b5c7d5c72b64 100644 --- a/src/sentry/integrations/source_code_management/auto_link_repos.py +++ b/src/sentry/integrations/source_code_management/auto_link_repos.py @@ -46,7 +46,8 @@ def get_repo_name_candidates(repo_name: str) -> list[str]: def auto_link_repos_by_name( organization: Organization | RpcOrganization, - repo_ids: Sequence[int], + repo_ids: Sequence[int] | None = None, + project_ids: Sequence[int] | None = None, ) -> int: """ Auto-link repositories to projects by matching repo name suffix to project slug. @@ -57,39 +58,40 @@ def auto_link_repos_by_name( Constraints: - The repo must not already be linked to any project. - The project must not already have any ProjectRepository link. + - If repo_ids is provided, only consider those repos. Otherwise all unlinked + repos in the org are considered. + - If project_ids is provided, only consider those projects. Returns the number of links created """ - if not repo_ids: - return 0 - if not features.has("organizations:auto-link-repos-by-name", organization): return 0 dry_run = options.get("repository.auto-link-by-name-dry-run") - repos = Repository.objects.filter( - id__in=repo_ids, + repo_qs = Repository.objects.filter( organization_id=organization.id, status=ObjectStatus.ACTIVE, ).exclude(Exists(ProjectRepository.objects.filter(repository_id=OuterRef("id")))) + if repo_ids is not None: + repo_qs = repo_qs.filter(id__in=repo_ids) + + project_qs = Project.objects.filter( + organization_id=organization.id, + status=ObjectStatus.ACTIVE, + ).exclude(Exists(ProjectRepository.objects.filter(project_id=OuterRef("id")))) + if project_ids is not None: + project_qs = project_qs.filter(id__in=project_ids) unlinked_projects_by_slug: dict[str, tuple[int, str]] = {} - for p_id, slug in ( - Project.objects.filter( - organization_id=organization.id, - status=ObjectStatus.ACTIVE, - ) - .exclude(Exists(ProjectRepository.objects.filter(project_id=OuterRef("id")))) - .values_list("id", "slug") - ): + for p_id, slug in project_qs.values_list("id", "slug"): unlinked_projects_by_slug[slug] = (p_id, slug) if not unlinked_projects_by_slug: return 0 created = 0 - for repo in repos: + for repo in repo_qs: project_id: int | None = None project_slug: str | None = None for candidate in get_repo_name_candidates(repo.name): @@ -132,3 +134,11 @@ def auto_link_repos_by_name( created += 1 return created + + +def auto_link_repos_on_project_create(project: Project, **kwargs: object) -> None: + """ + Signal receiver for project_created. Tries to match all unlinked repos + in the org to the newly created project by name. + """ + auto_link_repos_by_name(project.organization, project_ids=[project.id]) diff --git a/src/sentry/integrations/source_code_management/receivers.py b/src/sentry/integrations/source_code_management/receivers.py new file mode 100644 index 00000000000000..61b3631799647a --- /dev/null +++ b/src/sentry/integrations/source_code_management/receivers.py @@ -0,0 +1,10 @@ +from sentry.integrations.source_code_management.auto_link_repos import ( + auto_link_repos_on_project_create, +) +from sentry.signals import project_created + +project_created.connect( + auto_link_repos_on_project_create, + weak=False, + dispatch_uid="auto_link_repos_on_project_create", +) 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..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 {} @@ -750,7 +757,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 +783,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 +966,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/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/group_hashes.py b/src/sentry/issues/endpoints/group_hashes.py index 9793a0768d7bc6..38889f72a46d02 100644 --- a/src/sentry/issues/endpoints/group_hashes.py +++ b/src/sentry/issues/endpoints/group_hashes.py @@ -80,7 +80,7 @@ class GroupHashesEndpoint(GroupEndpoint): examples=EventExamples.GROUP_HASHES, ) @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-group-hashes"]) - def get(self, request: Request, group: Group) -> Response: + def get(self, request: Request, group: Group) -> Response[list[GroupHashesResult]]: """ List the hashes that make up an issue. Each hash represents a grouping signature used to aggregate individual events into this issue. diff --git a/src/sentry/issues/endpoints/group_integration_details.py b/src/sentry/issues/endpoints/group_integration_details.py index 7cd726438829df..1cc0b29fc183b4 100644 --- a/src/sentry/issues/endpoints/group_integration_details.py +++ b/src/sentry/issues/endpoints/group_integration_details.py @@ -75,10 +75,10 @@ def serialize( class GroupIntegrationDetailsEndpoint(GroupEndpoint): owner = ApiOwner.INTEGRATION_PLATFORM publish_status = { - "GET": ApiPublishStatus.UNKNOWN, - "POST": ApiPublishStatus.UNKNOWN, - "PUT": ApiPublishStatus.UNKNOWN, - "DELETE": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.PRIVATE, + "PUT": ApiPublishStatus.PRIVATE, + "DELETE": ApiPublishStatus.PRIVATE, } @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-group-integration-details"]) diff --git a/src/sentry/issues/endpoints/group_stats.py b/src/sentry/issues/endpoints/group_stats.py index 91bb5ec8af1504..9280b8322de220 100644 --- a/src/sentry/issues/endpoints/group_stats.py +++ b/src/sentry/issues/endpoints/group_stats.py @@ -16,7 +16,7 @@ @cell_silo_endpoint class GroupStatsEndpoint(GroupEndpoint, StatsMixin): publish_status = { - "GET": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, } @deprecated(CELL_API_DEPRECATION_DATE, url_names=["sentry-api-0-group-stats"]) diff --git a/src/sentry/issues/endpoints/group_tags.py b/src/sentry/issues/endpoints/group_tags.py index 0f87f1c0f82615..f6c1e0274de2e4 100644 --- a/src/sentry/issues/endpoints/group_tags.py +++ b/src/sentry/issues/endpoints/group_tags.py @@ -26,7 +26,7 @@ @cell_silo_endpoint class GroupTagsEndpoint(GroupEndpoint): publish_status = { - "GET": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, } enforce_rate_limit = True 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/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/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), + ] diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 844106a5990ca0..d138d634e3166a 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2179,6 +2179,13 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) +# Preprod snapshot comparison controls +register( + "preprod.snapshots.odiff-worker-count", + default=4, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) + # Webhook processing controls register( "hybridcloud.webhookpayload.worker_threads", 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/preprod/snapshots/tasks.py b/src/sentry/preprod/snapshots/tasks.py index cc9d17a56c257b..5220a1059624d0 100644 --- a/src/sentry/preprod/snapshots/tasks.py +++ b/src/sentry/preprod/snapshots/tasks.py @@ -1,7 +1,13 @@ from __future__ import annotations +import contextlib import logging +import queue import threading +import time +from collections import Counter +from collections.abc import Callable +from concurrent.futures import as_completed from difflib import SequenceMatcher from typing import NamedTuple @@ -13,6 +19,7 @@ from pydantic import ValidationError from taskbroker_client.retry import Retry +from sentry import options from sentry.objectstore import get_preprod_session from sentry.preprod.models import PreprodArtifact, PreprodComparisonApproval from sentry.preprod.snapshots.image_diff.compare import DIFF_ALGORITHM_VERSION, compare_images_batch @@ -20,6 +27,7 @@ from sentry.preprod.snapshots.manifest import ( ComparisonManifest, ComparisonSummary, + ImageMetadata, SnapshotManifest, ) from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics @@ -35,6 +43,26 @@ MAX_DIFF_PIXELS = 40_000_000 MAX_PIXELS_PER_BATCH = 40_000_000 +# Concurrent batch fetches/uploads make transient objectstore 429/503s likelier. Retry once +# (more would just amplify rate limiting); other errors (e.g. 404) fail fast. +_RETRYABLE_OBJECTSTORE_STATUSES = frozenset({429, 503}) +_OBJECTSTORE_MAX_ATTEMPTS = 2 +_OBJECTSTORE_RETRY_DELAY_S = 0.5 + + +def _retry_objectstore[T](operation: Callable[[], T]) -> T: + for attempt in range(1, _OBJECTSTORE_MAX_ATTEMPTS + 1): + try: + return operation() + except RequestError as e: + if ( + e.status not in _RETRYABLE_OBJECTSTORE_STATUSES + or attempt == _OBJECTSTORE_MAX_ATTEMPTS + ): + raise + time.sleep(_OBJECTSTORE_RETRY_DELAY_S) + raise AssertionError("unreachable") + class _DiffCandidate(NamedTuple): name: str @@ -43,6 +71,16 @@ class _DiffCandidate(NamedTuple): pixel_count: int +class _BatchResult(NamedTuple): + image_results: dict[str, dict[str, object]] + changed: int + unchanged: int + errored: int + fetched_bytes: int + fetched_count: int + duration_s: float + + class _ImageDiffResult(NamedTuple): renamed_pairs: list[tuple[str, str]] added: set[str] @@ -170,7 +208,9 @@ def _fetch_batch_images( def fetch(image_hash: str) -> None: try: - data = session.get(f"{key_prefix}/{image_hash}").payload.read() + data = _retry_objectstore( + lambda: session.get(f"{key_prefix}/{image_hash}").payload.read() + ) with lock: cache[image_hash] = data except Exception: @@ -204,6 +244,168 @@ def _create_pixel_batches( return batches +def _tally_statuses(results: dict[str, dict[str, object]]) -> tuple[int, int, int]: + counts = Counter(r["status"] for r in results.values()) + return counts["changed"], counts["unchanged"], counts["errored"] + + +def _process_batch( + batch: list[_DiffCandidate], + *, + server_pool: queue.Queue[OdiffServer], + session: Session, + image_key_prefix: str, + head_artifact_id: int, + base_artifact_id: int, + head_images: dict[str, ImageMetadata], + diff_threshold: float | None, +) -> _BatchResult: + started = time.monotonic() + image_results: dict[str, dict[str, object]] = {} + fetched_bytes = fetched_count = 0 + + # OdiffServer and the objectstore Session are shared across worker threads: + # each server is checked out of the pool so only one thread uses it at a time, + # and Session.get/.put are thread-safe (stateless per request over a pooled connection). + server = server_pool.get() + try: + diff_pairs: list[tuple[bytes, bytes]] = [] + batch_names: list[str] = [] + batch_hashes: list[tuple[str, str]] = [] + + unique_hashes: set[str] = set() + for candidate in batch: + unique_hashes.add(candidate.head_hash) + unique_hashes.add(candidate.base_hash) + + fetch_cache, failed_hashes = _fetch_batch_images(session, image_key_prefix, unique_hashes) + + for candidate in batch: + if candidate.head_hash in failed_hashes or candidate.base_hash in failed_hashes: + logger.warning( + "compare_snapshots: failed to fetch images for %s", + candidate.name, + extra={ + "head_artifact_id": head_artifact_id, + "head_hash": candidate.head_hash, + "base_hash": candidate.base_hash, + }, + ) + image_results[candidate.name] = { + "status": "errored", + "head_hash": candidate.head_hash, + "base_hash": candidate.base_hash, + "reason": "image_fetch_failed", + } + continue + head_data = fetch_cache[candidate.head_hash] + base_data = fetch_cache[candidate.base_hash] + fetched_bytes += len(head_data) + len(base_data) + fetched_count += 2 + diff_pairs.append((base_data, head_data)) + batch_names.append(candidate.name) + batch_hashes.append((candidate.head_hash, candidate.base_hash)) + + logger.info( + "compare_snapshots: running batch of %d pairs (%d unique hashes fetched)", + len(diff_pairs), + len(fetch_cache), + extra={"head_artifact_id": head_artifact_id, "names": batch_names}, + ) + diff_results = compare_images_batch(diff_pairs, server=server) + + for name, (head_hash, base_hash), diff_result in zip( + batch_names, batch_hashes, diff_results, strict=True + ): + if diff_result is None: + image_results[name] = { + "status": "errored", + "head_hash": head_hash, + "base_hash": base_hash, + "reason": "image_processing_failed", + } + continue + + stem = _image_name_to_path_stem(name) + diff_mask_key = ( + f"{image_key_prefix}/{head_artifact_id}/{base_artifact_id}/diff/{stem}.png" + ) + _retry_objectstore( + lambda: session.put( + diff_result.diff_mask_png, + key=diff_mask_key, + content_type="image/png", + ) + ) + + diff_pct = ( + diff_result.changed_pixels / diff_result.total_pixels + if diff_result.total_pixels > 0 + else 0 + ) + effective_threshold = next( + t for t in (head_images[name].diff_threshold, diff_threshold, 0.0) if t is not None + ) + is_changed = diff_pct > effective_threshold + + diff_mask_image_id = f"{head_artifact_id}/{base_artifact_id}/diff/{stem}.png" + image_results[name] = { + "status": "changed" if is_changed else "unchanged", + "head_hash": head_hash, + "base_hash": base_hash, + "changed_pixels": diff_result.changed_pixels, + "total_pixels": diff_result.total_pixels, + "diff_mask_key": diff_mask_key, + "diff_mask_image_id": diff_mask_image_id, + "before_width": diff_result.before_width, + "before_height": diff_result.before_height, + "after_width": diff_result.after_width, + "after_height": diff_result.after_height, + "aligned_height": diff_result.aligned_height, + } + except Exception: + # Isolate a batch-level failure: leave already-processed pairs intact and + # mark only the unprocessed ones errored, so partial progress survives. + logger.exception( + "compare_snapshots: batch failed", + extra={"head_artifact_id": head_artifact_id, "base_artifact_id": base_artifact_id}, + ) + for candidate in batch: + if candidate.name not in image_results: + image_results[candidate.name] = { + "status": "errored", + "head_hash": candidate.head_hash, + "base_hash": candidate.base_hash, + "reason": "batch_failed", + } + finally: + server_pool.put(server) + + changed, unchanged, errored = _tally_statuses(image_results) + duration_s = time.monotonic() - started + # Per-batch detail kept at DEBUG to avoid flooding prod with one line per batch; + # the INFO "odiff phase complete" summary carries the aggregate. + logger.debug( + "compare_snapshots: batch done", + extra={ + "head_artifact_id": head_artifact_id, + "pairs": len(batch), + "duration_s": round(duration_s, 3), + "changed": changed, + "errored": errored, + }, + ) + return _BatchResult( + image_results=image_results, + changed=changed, + unchanged=unchanged, + errored=errored, + fetched_bytes=fetched_bytes, + fetched_count=fetched_count, + duration_s=duration_s, + ) + + class ImageFingerprint(NamedTuple): name: str status: str @@ -332,7 +534,7 @@ def _try_auto_approve_snapshot( namespace=preprod_tasks, retry=Retry(times=3), silo_mode=SiloMode.CELL, - processing_deadline_duration=300, + processing_deadline_duration=600, ) def compare_snapshots( project_id: int, @@ -471,10 +673,14 @@ def compare_snapshots( try: head_manifest = SnapshotManifest( - **orjson.loads(session.get(head_manifest_key).payload.read()) + **orjson.loads( + _retry_objectstore(lambda: session.get(head_manifest_key).payload.read()) + ) ) base_manifest = SnapshotManifest( - **orjson.loads(session.get(base_manifest_key).payload.read()) + **orjson.loads( + _retry_objectstore(lambda: session.get(base_manifest_key).payload.read()) + ) ) except (orjson.JSONDecodeError, RequestError, ValidationError, TypeError): logger.exception( @@ -569,6 +775,8 @@ def compare_snapshots( "eligible_for_diff": len(eligible), "unchanged_count": unchanged_count, "error_count": error_count, + # Time spent before odiff (manifest load + categorize + matching). + "duration_s": round((timezone.now() - task_start_time).total_seconds(), 3), }, ) @@ -577,149 +785,69 @@ def compare_snapshots( batches = _create_pixel_batches(eligible, MAX_PIXELS_PER_BATCH) + worker_count = max( + 1, min(options.get("preprod.snapshots.odiff-worker-count"), len(batches)) + ) logger.info( "compare_snapshots: starting odiff, %d batches, %d pairs", len(batches), len(eligible), - extra={"head_artifact_id": head_artifact_id}, + extra={"head_artifact_id": head_artifact_id, "worker_count": worker_count}, ) - # TODO: spawn N OdiffServer workers and distribute pairs across them - # via a thread pool to parallelize the odiff comparison step per batch - with OdiffServer() as server: - for batch in batches: - diff_pairs: list[tuple[bytes, bytes]] = [] - batch_names: list[str] = [] - batch_hashes: list[tuple[str, str]] = [] - - unique_hashes: set[str] = set() - for candidate in batch: - unique_hashes.add(candidate.head_hash) - unique_hashes.add(candidate.base_hash) - - # Fetch unique hashes in parallel; session.get() is thread-safe - fetch_cache, failed_hashes = _fetch_batch_images( - session, image_key_prefix, unique_hashes - ) - - for candidate in batch: - if candidate.head_hash in failed_hashes or candidate.base_hash in failed_hashes: - logger.warning( - "compare_snapshots: failed to fetch images for %s", - candidate.name, - extra={ - "head_artifact_id": head_artifact_id, - "head_hash": candidate.head_hash, - "base_hash": candidate.base_hash, - }, + odiff_phase_start = time.monotonic() + slowest_batch_s = 0.0 + + # Guarded so a comparison with nothing to diff doesn't spawn an idle odiff subprocess. + if batches: + with contextlib.ExitStack() as stack: + server_pool: queue.Queue[OdiffServer] = queue.Queue() + for _ in range(worker_count): + server_pool.put(stack.enter_context(OdiffServer())) + + with ContextPropagatingThreadPoolExecutor(max_workers=worker_count) as executor: + futures = [ + executor.submit( + _process_batch, + batch, + server_pool=server_pool, + session=session, + image_key_prefix=image_key_prefix, + head_artifact_id=head_artifact_id, + base_artifact_id=base_artifact_id, + head_images=head_images, + diff_threshold=diff_threshold, ) - error_count += 1 - image_results[candidate.name] = { - "status": "errored", - "head_hash": candidate.head_hash, - "base_hash": candidate.base_hash, - "reason": "image_fetch_failed", - } - continue - head_data = fetch_cache[candidate.head_hash] - base_data = fetch_cache[candidate.base_hash] - total_fetched_bytes += len(head_data) + len(base_data) - total_fetched_count += 2 - diff_pairs.append((base_data, head_data)) - batch_names.append(candidate.name) - batch_hashes.append((candidate.head_hash, candidate.base_hash)) - - logger.info( - "compare_snapshots: running batch of %d pairs (%d unique hashes fetched)", - len(diff_pairs), - len(fetch_cache), - extra={"head_artifact_id": head_artifact_id, "names": batch_names}, - ) - diff_results = compare_images_batch(diff_pairs, server=server) - logger.info( - "compare_snapshots: batch complete, %d results", - len(diff_results), - extra={"head_artifact_id": head_artifact_id}, - ) - - for name, (head_hash, base_hash), diff_result in zip( - batch_names, batch_hashes, diff_results, strict=True - ): - if diff_result is None: - error_count += 1 - image_results[name] = { - "status": "errored", - "head_hash": head_hash, - "base_hash": base_hash, - "reason": "image_processing_failed", - } - continue - - stem = _image_name_to_path_stem(name) - diff_mask_key = ( - f"{image_key_prefix}/{head_artifact_id}/{base_artifact_id}/diff/{stem}.png" - ) - diff_mask_bytes = diff_result.diff_mask_png - logger.info( - "compare_snapshots: uploading mask for %s (%d bytes, changed_px=%d)", - name, - len(diff_mask_bytes), - diff_result.changed_pixels, - extra={ - "head_artifact_id": head_artifact_id, - "diff_mask_key": diff_mask_key, - }, - ) - session.put(diff_mask_bytes, key=diff_mask_key, content_type="image/png") - - diff_pct = ( - diff_result.changed_pixels / diff_result.total_pixels - if diff_result.total_pixels > 0 - else 0 - ) - specific_image_diff_threshold = head_images[name].diff_threshold - effective_threshold = ( - specific_image_diff_threshold - if specific_image_diff_threshold is not None - else diff_threshold - if diff_threshold is not None - else 0.0 - ) - is_changed = diff_pct > effective_threshold - if is_changed: - changed_count += 1 - else: - unchanged_count += 1 - - logger.debug( - "compare_snapshots: %s diff_pct=%.6f threshold=%s (per_image=%s global=%s) is_changed=%s pixels=%d/%d", - name, - diff_pct, - effective_threshold, - specific_image_diff_threshold, - diff_threshold, - is_changed, - diff_result.changed_pixels, - diff_result.total_pixels, - extra={"head_artifact_id": head_artifact_id}, - ) - - diff_mask_image_id = f"{head_artifact_id}/{base_artifact_id}/diff/{stem}.png" - - image_results[name] = { - "status": "changed" if is_changed else "unchanged", - "head_hash": head_hash, - "base_hash": base_hash, - "changed_pixels": diff_result.changed_pixels, - "total_pixels": diff_result.total_pixels, - "diff_mask_key": diff_mask_key, - "diff_mask_image_id": diff_mask_image_id, - "before_width": diff_result.before_width, - "before_height": diff_result.before_height, - "after_width": diff_result.after_width, - "after_height": diff_result.after_height, - "aligned_height": diff_result.aligned_height, - } + for batch in batches + ] + for future in as_completed(futures): + result = future.result() + image_results.update(result.image_results) + changed_count += result.changed + unchanged_count += result.unchanged + error_count += result.errored + total_fetched_bytes += result.fetched_bytes + total_fetched_count += result.fetched_count + slowest_batch_s = max(slowest_batch_s, result.duration_s) + + odiff_phase_s = time.monotonic() - odiff_phase_start + # Throughput reflects only the pairs odiff actually processed (eligible); + # hash-identical and oversized images are skipped before the phase starts. + odiff_pairs = len(eligible) + logger.info( + "compare_snapshots: odiff phase complete", + extra={ + "head_artifact_id": head_artifact_id, + "base_artifact_id": base_artifact_id, + "worker_count": worker_count, + "batches": len(batches), + "odiff_pairs": odiff_pairs, + "duration_s": round(odiff_phase_s, 3), + "slowest_batch_s": round(slowest_batch_s, 3), + "pairs_per_s": round(odiff_pairs / odiff_phase_s, 2) if odiff_phase_s > 0 else 0, + "fetched_mb": round(total_fetched_bytes / 1_000_000, 2), + }, + ) for name in sorted(added): image_results[name] = {"status": "added", "head_hash": head_by_name[name]} @@ -772,10 +900,12 @@ def compare_snapshots( comparison_key = ( f"{org_id}/{project_id}/{head_artifact_id}/{base_artifact_id}/comparison.json" ) - session.put( - orjson.dumps(comparison_manifest.dict()), - key=comparison_key, - content_type="application/json", + _retry_objectstore( + lambda: session.put( + orjson.dumps(comparison_manifest.dict()), + key=comparison_key, + content_type="application/json", + ) ) comparison.state = PreprodSnapshotComparison.State.SUCCESS 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/src/sentry/receivers/__init__.py b/src/sentry/receivers/__init__.py index 281623063670e8..ee7be7f84b7c1c 100644 --- a/src/sentry/receivers/__init__.py +++ b/src/sentry/receivers/__init__.py @@ -1,3 +1,5 @@ +import sentry.integrations.source_code_management.receivers # noqa: F401,E402 + from .analytics import * # noqa: F401,F403 from .auth import * # noqa: F401,F403 from .core import * # noqa: F401,F403 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 diff --git a/src/sentry/releases/endpoints/organization_release_commits.py b/src/sentry/releases/endpoints/organization_release_commits.py index 47096295096b5e..d8fd1dfb9eeb0d 100644 --- a/src/sentry/releases/endpoints/organization_release_commits.py +++ b/src/sentry/releases/endpoints/organization_release_commits.py @@ -2,37 +2,49 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationReleasesBaseEndpoint from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize -from sentry.apidocs.parameters import CursorQueryParam +from sentry.api.serializers.models.commit import CommitSerializerResponse +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.examples.release_examples import ReleaseExamples +from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, ReleaseParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit +@extend_schema(tags=["Releases"]) @cell_silo_endpoint class OrganizationReleaseCommitsEndpoint(OrganizationReleasesBaseEndpoint): + owner = ApiOwner.TELEMETRY_EXPERIENCE publish_status = { - "GET": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, } @extend_schema( operation_id="List an Organization Release's Commits", - parameters=[CursorQueryParam], + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + ReleaseParams.VERSION, + CursorQueryParam, + ], + responses={ + 200: inline_sentry_response_serializer( + "ListOrganizationReleaseCommitsResponse", list[CommitSerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=ReleaseExamples.LIST_RELEASE_COMMITS, ) def get(self, request: Request, organization, version) -> Response: """ - List an Organization Release's Commits - `````````````````````````````````````` - Retrieve a list of commits for a given release. - - :pparam string organization_id_or_slug: the id or slug of the organization the - release belongs to. - :pparam string version: the version identifier of the release. - :auth: required """ try: release = Release.objects.distinct().get( diff --git a/src/sentry/releases/endpoints/project_release_commits.py b/src/sentry/releases/endpoints/project_release_commits.py index d062e36c201012..1ba168a828b2c7 100644 --- a/src/sentry/releases/endpoints/project_release_commits.py +++ b/src/sentry/releases/endpoints/project_release_commits.py @@ -2,45 +2,53 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize -from sentry.apidocs.parameters import CursorQueryParam +from sentry.api.serializers.models.commit import CommitSerializerResponse +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED +from sentry.apidocs.examples.release_examples import ReleaseExamples +from sentry.apidocs.parameters import CursorQueryParam, GlobalParams, ReleaseParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ObjectStatus from sentry.models.release import Release from sentry.models.releasecommit import ReleaseCommit from sentry.models.repository import Repository +@extend_schema(tags=["Releases"]) @cell_silo_endpoint class ProjectReleaseCommitsEndpoint(ProjectEndpoint): + owner = ApiOwner.TELEMETRY_EXPERIENCE publish_status = { - "GET": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, } permission_classes = (ProjectReleasePermission,) @extend_schema( operation_id="List a Project Release's Commits", - parameters=[CursorQueryParam], + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + ReleaseParams.VERSION, + CursorQueryParam, + ], + responses={ + 200: inline_sentry_response_serializer( + "ListProjectReleaseCommitsResponse", list[CommitSerializerResponse] + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=ReleaseExamples.LIST_RELEASE_COMMITS, ) def get(self, request: Request, project, version) -> Response: """ - List a Project Release's Commits - ```````````````````````````````` - Retrieve a list of commits for a given release. - - :pparam string organization_id_or_slug: the id or slug of the organization the - release belongs to. - :pparam string project_id_or_slug: the id or slug of the project to list the - release files of. - :pparam string version: the version identifier of the release. - - :pparam string repo_name: the repository name - - :auth: required """ organization_id = project.organization_id diff --git a/src/sentry/releases/endpoints/project_release_details.py b/src/sentry/releases/endpoints/project_release_details.py index e37f832bd04cb6..64624863904cb5 100644 --- a/src/sentry/releases/endpoints/project_release_details.py +++ b/src/sentry/releases/endpoints/project_release_details.py @@ -1,9 +1,11 @@ import sentry_sdk +from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError from rest_framework.request import Request from rest_framework.response import Response from sentry import options +from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import ReleaseAnalyticsMixin, cell_silo_endpoint from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission @@ -11,6 +13,17 @@ from sentry.api.exceptions import ResourceDoesNotExist from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework import ReleaseSerializer +from sentry.api.serializers.types import ReleaseSerializerResponse +from sentry.apidocs.constants import ( + RESPONSE_BAD_REQUEST, + RESPONSE_FORBIDDEN, + RESPONSE_NO_CONTENT, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.examples.release_examples import ReleaseExamples +from sentry.apidocs.parameters import GlobalParams, ReleaseParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.models.activity import Activity from sentry.models.release import Release from sentry.models.releases.exceptions import UnsafeReleaseDeletion @@ -20,28 +33,39 @@ from sentry.utils.sdk import bind_organization_context +@extend_schema(tags=["Releases"]) @cell_silo_endpoint class ProjectReleaseDetailsEndpoint(ProjectEndpoint, ReleaseAnalyticsMixin): + owner = ApiOwner.TELEMETRY_EXPERIENCE publish_status = { - "DELETE": ApiPublishStatus.UNKNOWN, - "GET": ApiPublishStatus.UNKNOWN, - "PUT": ApiPublishStatus.UNKNOWN, + "DELETE": ApiPublishStatus.PRIVATE, + "GET": ApiPublishStatus.PRIVATE, + "PUT": ApiPublishStatus.PRIVATE, } permission_classes = (ProjectReleasePermission,) + @extend_schema( + operation_id="Retrieve a Project's Release", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + ReleaseParams.VERSION, + ReleaseParams.SUMMARY_STATS_PERIOD, + ReleaseParams.HEALTH_STATS_PERIOD, + ], + responses={ + 200: inline_sentry_response_serializer( + "ProjectReleaseResponse", ReleaseSerializerResponse + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=ReleaseExamples.RETRIEVE_RELEASE, + ) def get(self, request: Request, project, version) -> Response: """ - Retrieve a Project's Release - ```````````````````````````` - Return details on an individual release. - - :pparam string organization_id_or_slug: the id or slug of the organization the - release belongs to. - :pparam string project_id_or_slug: the id or slug of the project to retrieve the - release of. - :pparam string version: the version identifier of the release. - :auth: required """ with_health = request.GET.get("health") == "1" summary_stats_period = request.GET.get("summaryStatsPeriod") or "14d" @@ -72,28 +96,28 @@ def get(self, request: Request, project, version) -> Response: ) ) + @extend_schema( + operation_id="Update a Project's Release", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + ReleaseParams.VERSION, + ], + request=ReleaseSerializer, + responses={ + 200: inline_sentry_response_serializer( + "UpdateProjectReleaseResponse", ReleaseSerializerResponse + ), + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + ) def put(self, request: Request, project, version) -> Response: """ - Update a Project's Release - `````````````````````````` - - Update a release. This can change some metadata associated with - the release (the ref, url, and dates). - - :pparam string organization_id_or_slug: the id or slug of the organization the - release belongs to. - :pparam string project_id_or_slug: the id or slug of the project to change the - release of. - :pparam string version: the version identifier of the release. - :param string ref: an optional commit reference. This is useful if - a tagged version has been provided. - :param url url: a URL that points to the release. This can be the - path to an online interface to the sourcecode - for instance. - :param datetime dateReleased: an optional date that indicates when - the release went live. If not provided - the current time is assumed. - :auth: required + Update a release. This can change metadata associated with the release + (its ref, url, dates, and status) and associate commits with it. """ bind_organization_context(project.organization) scope = sentry_sdk.get_isolation_scope() @@ -153,19 +177,24 @@ def put(self, request: Request, project, version) -> Response: ) ) + @extend_schema( + operation_id="Delete a Project's Release", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + ReleaseParams.VERSION, + ], + responses={ + 204: RESPONSE_NO_CONTENT, + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + ) def delete(self, request: Request, project, version) -> Response: """ - Delete a Project's Release - `````````````````````````` - Permanently remove a release and all of its files. - - :pparam string organization_id_or_slug: the id or slug of the organization the - release belongs to. - :pparam string project_id_or_slug: the id or slug of the project to delete the - release of. - :pparam string version: the version identifier of the release. - :auth: required """ try: release = Release.objects.get( diff --git a/src/sentry/releases/endpoints/project_releases.py b/src/sentry/releases/endpoints/project_releases.py index 7b531f67ef03a9..1b893e8ab7e070 100644 --- a/src/sentry/releases/endpoints/project_releases.py +++ b/src/sentry/releases/endpoints/project_releases.py @@ -20,6 +20,8 @@ from sentry.api.serializers.types import ReleaseSerializerResponse from sentry.api.utils import get_auth_api_token_type from sentry.apidocs.constants import ( + RESPONSE_ALREADY_REPORTED, + RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED, @@ -44,7 +46,7 @@ class ProjectReleasesEndpoint(ProjectEndpoint): owner = ApiOwner.TELEMETRY_EXPERIENCE publish_status = { "GET": ApiPublishStatus.PUBLIC, - "POST": ApiPublishStatus.UNKNOWN, + "POST": ApiPublishStatus.PRIVATE, } permission_classes = (ProjectReleasePermission,) rate_limits = RateLimitConfig( @@ -70,7 +72,7 @@ class ProjectReleasesEndpoint(ProjectEndpoint): }, examples=ReleaseExamples.LIST_PROJECT_RELEASES, ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project) -> Response[list[ReleaseSerializerResponse]]: """ Retrieve a list of releases for a given project. """ @@ -104,37 +106,33 @@ def get(self, request: Request, project) -> Response: ), ) + @extend_schema( + operation_id="Create a New Release for a Project", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + ], + request=ReleaseWithVersionSerializer, + responses={ + 201: inline_sentry_response_serializer( + "CreateProjectReleaseResponse", ReleaseSerializerResponse + ), + 208: RESPONSE_ALREADY_REPORTED, + 400: RESPONSE_BAD_REQUEST, + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + }, + examples=ReleaseExamples.CREATE_RELEASE, + ) def post(self, request: Request, project) -> Response: """ - Create a New Release for a Project - `````````````````````````````````` - - Create a new release and/or associate a project with a release. - Release versions that are the same across multiple projects - within an Organization will be treated as the same release in Sentry. - - Releases are used by Sentry to improve its error reporting abilities - by correlating first seen events with the release that might have - introduced the problem. - - Releases are also necessary for sourcemaps and other debug features - that require manual upload for functioning well. - - :pparam string organization_id_or_slug: the id or slug of the organization the - release belongs to. - :pparam string project_id_or_slug: the id or slug of the project to create a - release for. - :param string version: a version identifier for this release. Can - be a version number, a commit hash etc. - :param string ref: an optional commit reference. This is useful if - a tagged version has been provided. - :param url url: a URL that points to the release. This can be the - path to an online interface to the sourcecode - for instance. - :param datetime dateReleased: an optional date that indicates when - the release went live. If not provided - the current time is assumed. - :auth: required + Create a new release and/or associate a project with a release. Releases are used by + Sentry to improve error reporting by correlating first-seen events with the release + that may have introduced them, and are required for source maps and other debug + features. + + Release versions that are the same across multiple projects within an organization + are treated as the same release in Sentry. """ bind_organization_context(project.organization) serializer = ReleaseWithVersionSerializer( diff --git a/src/sentry/releases/endpoints/project_releases_token.py b/src/sentry/releases/endpoints/project_releases_token.py index 6dfc004c84051e..94272d0ae8aea1 100644 --- a/src/sentry/releases/endpoints/project_releases_token.py +++ b/src/sentry/releases/endpoints/project_releases_token.py @@ -38,8 +38,8 @@ def _get_signature(project_id, plugin_id, token): @cell_silo_endpoint class ProjectReleasesTokenEndpoint(ProjectEndpoint): publish_status = { - "GET": ApiPublishStatus.UNKNOWN, - "POST": ApiPublishStatus.UNKNOWN, + "GET": ApiPublishStatus.PRIVATE, + "POST": ApiPublishStatus.PRIVATE, } permission_classes = (StrictProjectPermission, DisallowImpersonatedTokenCreation) 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/src/sentry/replays/endpoints/project_replay_jobs_delete.py b/src/sentry/replays/endpoints/project_replay_jobs_delete.py index 32dd4ccbbf127d..1750ba27fe6a84 100644 --- a/src/sentry/replays/endpoints/project_replay_jobs_delete.py +++ b/src/sentry/replays/endpoints/project_replay_jobs_delete.py @@ -18,6 +18,7 @@ from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND from sentry.apidocs.examples.replay_examples import ReplayExamples from sentry.apidocs.parameters import GlobalParams, ReplayParams +from sentry.apidocs.response_types import ValidationErrorResponse, as_validation_errors from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.replays.endpoints.project_replay_endpoint import ProjectReplayEndpoint from sentry.replays.models import ReplayDeletionJobModel @@ -109,7 +110,7 @@ class ProjectReplayDeletionJobsIndexEndpoint(ProjectEndpoint): }, examples=ReplayExamples.GET_REPLAY_DELETION_JOBS, ) - def get(self, request: Request, project) -> Response: + def get(self, request: Request, project) -> Response[ReplayDeletionJobListResponse]: """ Retrieve a collection of replay delete jobs. """ @@ -146,7 +147,9 @@ def get(self, request: Request, project) -> Response: }, examples=ReplayExamples.CREATE_REPLAY_DELETION_JOB, ) - def post(self, request: Request, project) -> Response: + def post( + self, request: Request, project + ) -> Response[ReplayDeletionJobDetailResponse] | Response[ValidationErrorResponse]: """ Create a new replay deletion job. """ @@ -155,7 +158,7 @@ def post(self, request: Request, project) -> Response: serializer = ReplayDeletionJobCreateSerializer(data=request.data) if not serializer.is_valid(): - return Response(serializer.errors, status=400) + return Response(as_validation_errors(serializer), status=400) data = serializer.validated_data["data"] @@ -186,7 +189,7 @@ def post(self, request: Request, project) -> Response: ) response_data = serialize(job, request.user, ReplayDeletionJobSerializer()) - response = {"data": response_data} + response: ReplayDeletionJobDetailResponse = {"data": response_data} return Response(response, status=201) @@ -215,7 +218,9 @@ class ProjectReplayDeletionJobDetailEndpoint(ProjectReplayEndpoint): }, examples=ReplayExamples.GET_REPLAY_DELETION_JOB, ) - def get(self, request: Request, project, job_id: int) -> Response: + def get( + self, request: Request, project, job_id: int + ) -> Response[ReplayDeletionJobDetailResponse]: """ Fetch a replay delete job instance. """ 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/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 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/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/src/sentry/templates/sentry/partial/alerts.html b/src/sentry/templates/sentry/partial/alerts.html index 311ff67265ac22..f143a3d45ae8da 100644 --- a/src/sentry/templates/sentry/partial/alerts.html +++ b/src/sentry/templates/sentry/partial/alerts.html @@ -28,11 +28,12 @@ /* Default alert banner color */ .alert-banner.default { - background: rgb(255, 219, 74); - color: #3e3446; + background: #6c5fc7; + color: white; } .alert-banner.default a { - color: #2562d4; + color: white; + text-decoration: underline; } /* Partner login banner color */ 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/src/sentry/workflow_engine/apps.py b/src/sentry/workflow_engine/apps.py index 6145830a03714b..bacc9531b97bfa 100644 --- a/src/sentry/workflow_engine/apps.py +++ b/src/sentry/workflow_engine/apps.py @@ -6,7 +6,7 @@ class Config(AppConfig): def ready(self) -> None: # Import items that use registries or respond to events - import sentry.workflow_engine.handlers # NOQA + import sentry.workflow_engine.handlers.condition # NOQA + import sentry.workflow_engine.handlers.workflow # NOQA import sentry.workflow_engine.receivers # NOQA - import sentry.workflow_engine.handlers.workflow.workflow_activity_handlers # NOQA from sentry.workflow_engine.endpoints import serializers # NOQA 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/src/sentry/workflow_engine/endpoints/organization_data_condition_index.py b/src/sentry/workflow_engine/endpoints/organization_data_condition_index.py index 147a95712bd33f..56ac089d3d876b 100644 --- a/src/sentry/workflow_engine/endpoints/organization_data_condition_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_data_condition_index.py @@ -3,6 +3,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint @@ -22,7 +23,7 @@ DataConditionHandlerResponse, DataConditionHandlerSerializer, ) -from sentry.workflow_engine.models.data_condition import LEGACY_CONDITIONS +from sentry.workflow_engine.models.data_condition import LEGACY_CONDITIONS, Condition from sentry.workflow_engine.registry import condition_handler_registry from sentry.workflow_engine.types import DataConditionHandler @@ -66,6 +67,11 @@ def get( data_conditions = [] for condition_type, handler in condition_handler_registry.registrations.items(): + if condition_type == Condition.SEER_ACTIVITY_TRIGGER and not features.has( + "organizations:workflow-engine-evaluate-seer-activities", organization + ): + continue + if condition_type not in LEGACY_CONDITIONS and handler.group == group: serialized = serialize( handler, diff --git a/src/sentry/workflow_engine/handlers/__init__.py b/src/sentry/workflow_engine/handlers/__init__.py index 083a78b487003a..e69de29bb2d1d6 100644 --- a/src/sentry/workflow_engine/handlers/__init__.py +++ b/src/sentry/workflow_engine/handlers/__init__.py @@ -1,9 +0,0 @@ -# Export any handlers we want to include into the registry -__all__ = [ - "EventCreatedByDetectorConditionHandler", - "EventSeenCountConditionHandler", - "workflow_status_update_handler", -] - -from .condition import EventCreatedByDetectorConditionHandler, EventSeenCountConditionHandler -from .workflow import workflow_status_update_handler diff --git a/src/sentry/workflow_engine/handlers/condition/__init__.py b/src/sentry/workflow_engine/handlers/condition/__init__.py index 5a89bc834e288b..a7f6d5881d6391 100644 --- a/src/sentry/workflow_engine/handlers/condition/__init__.py +++ b/src/sentry/workflow_engine/handlers/condition/__init__.py @@ -25,6 +25,7 @@ "PercentSessionsPercentHandler", "ReappearedEventConditionHandler", "RegressionEventConditionHandler", + "SeerActivityTriggerHandler", "TaggedEventConditionHandler", ] @@ -51,4 +52,5 @@ from .new_high_priority_issue_handler import NewHighPriorityIssueConditionHandler from .reappeared_event_handler import ReappearedEventConditionHandler from .regression_event_handler import RegressionEventConditionHandler +from .seer_activity_trigger_handler import SeerActivityTriggerHandler from .tagged_event_handler import TaggedEventConditionHandler 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) diff --git a/src/sentry/workflow_engine/handlers/condition/seer_activity_trigger_handler.py b/src/sentry/workflow_engine/handlers/condition/seer_activity_trigger_handler.py index a0fde770b6d6ef..1d75145237b4c1 100644 --- a/src/sentry/workflow_engine/handlers/condition/seer_activity_trigger_handler.py +++ b/src/sentry/workflow_engine/handlers/condition/seer_activity_trigger_handler.py @@ -3,6 +3,8 @@ from sentry.models.activity import Activity from sentry.types.activity import ActivityType +from sentry.workflow_engine.models.data_condition import Condition +from sentry.workflow_engine.registry import condition_handler_registry from sentry.workflow_engine.types import DataConditionHandler, WorkflowEventData @@ -34,6 +36,7 @@ class SeerActivityTriggerStage(StrEnum): """ +@condition_handler_registry.register(Condition.SEER_ACTIVITY_TRIGGER) class SeerActivityTriggerHandler(DataConditionHandler[WorkflowEventData]): group = DataConditionHandler.Group.WORKFLOW_TRIGGER comparison_json_schema = { diff --git a/src/sentry/workflow_engine/handlers/workflow/__init__.py b/src/sentry/workflow_engine/handlers/workflow/__init__.py index 15ddc82d9f2654..51261257a2e8b8 100644 --- a/src/sentry/workflow_engine/handlers/workflow/__init__.py +++ b/src/sentry/workflow_engine/handlers/workflow/__init__.py @@ -1,3 +1,7 @@ +from .workflow_activity_handlers import seer_activity_handler from .workflow_status_update_handler import workflow_status_update_handler -__all__ = ["workflow_status_update_handler"] +__all__ = [ + "seer_activity_handler", + "workflow_status_update_handler", +] 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/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, +}); diff --git a/static/app/components/events/interfaces/crashContent/exception/content.tsx b/static/app/components/events/interfaces/crashContent/exception/content.tsx index 7c3f945b7011ad..5de7c18201e33b 100644 --- a/static/app/components/events/interfaces/crashContent/exception/content.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/content.tsx @@ -10,7 +10,7 @@ import {StacktraceBanners} from 'sentry/components/events/interfaces/crashConten import { prepareSourceMapDebuggerFrameInformation, useSourceMapDebugQuery, - type SourceMapDebugBlueThunderResponse, + type SourceMapDebugResponse, } from 'sentry/components/events/interfaces/crashContent/exception/useSourceMapDebuggerData'; import {renderLinksInText} from 'sentry/components/events/interfaces/crashContent/exception/utils'; import {getStacktracePlatform} from 'sentry/components/events/interfaces/utils'; @@ -160,7 +160,7 @@ function InnerContent({ hasChainedExceptions: boolean; hiddenExceptions: ExceptionRenderStateMap; isSampleError: boolean; - sourceMapDebuggerData: SourceMapDebugBlueThunderResponse | undefined; + sourceMapDebuggerData: SourceMapDebugResponse | undefined; toggleRelatedExceptions: (exceptionId: number) => void; values: ExceptionValue[]; project?: Project; diff --git a/static/app/components/events/interfaces/crashContent/exception/useSourceMapDebuggerData.tsx b/static/app/components/events/interfaces/crashContent/exception/useSourceMapDebuggerData.tsx index 707b285e3b7e4c..9bda2dcf2e01ab 100644 --- a/static/app/components/events/interfaces/crashContent/exception/useSourceMapDebuggerData.tsx +++ b/static/app/components/events/interfaces/crashContent/exception/useSourceMapDebuggerData.tsx @@ -6,7 +6,7 @@ import {useApiQuery, type UseApiQueryResult} from 'sentry/utils/queryClient'; import type {RequestError} from 'sentry/utils/requestError/requestError'; import {useOrganization} from 'sentry/utils/useOrganization'; -export interface SourceMapDebugBlueThunderResponseFrame { +export interface SourceMapDebugResponseFrame { debug_id_process: { debug_id: string | null; uploaded_source_file_with_correct_debug_id: boolean; @@ -34,10 +34,10 @@ export interface SourceMapDebugBlueThunderResponseFrame { }; } -export interface SourceMapDebugBlueThunderResponse { +export interface SourceMapDebugResponse { dist: string | null; exceptions: Array<{ - frames: SourceMapDebugBlueThunderResponseFrame[]; + frames: SourceMapDebugResponseFrame[]; }>; has_debug_ids: boolean; has_uploaded_some_artifact_with_a_debug_id: boolean; @@ -51,7 +51,7 @@ export interface SourceMapDebugBlueThunderResponse { } export type SourceMapDebugQueryResult = UseApiQueryResult< - SourceMapDebugBlueThunderResponse, + SourceMapDebugResponse, RequestError >; @@ -63,10 +63,10 @@ export function useSourceMapDebugQuery( const organization = useOrganization({allowNull: true}); const isSdkThatShouldShowSourceMapsDebugger = sdkName?.startsWith('sentry.javascript.') ?? false; - return useApiQuery( + return useApiQuery( [ getApiUrl( - '/projects/$organizationIdOrSlug/$projectIdOrSlug/events/$eventId/source-map-debug-blue-thunder-edition/', + '/projects/$organizationIdOrSlug/$projectIdOrSlug/events/$eventId/source-map-debug/', { path: { organizationIdOrSlug: organization!.slug, @@ -90,8 +90,8 @@ export function useSourceMapDebugQuery( } function getDebugIdProgress( - sourceMapDebuggerData: SourceMapDebugBlueThunderResponse, - debuggerFrame: SourceMapDebugBlueThunderResponseFrame + sourceMapDebuggerData: SourceMapDebugResponse, + debuggerFrame: SourceMapDebugResponseFrame ) { let debugIdProgress = 0; if (sourceMapDebuggerData.sdk_debug_id_support === 'full') { @@ -110,8 +110,8 @@ function getDebugIdProgress( } function getReleaseProgress( - sourceMapDebuggerData: SourceMapDebugBlueThunderResponse, - debuggerFrame: SourceMapDebugBlueThunderResponseFrame + sourceMapDebuggerData: SourceMapDebugResponse, + debuggerFrame: SourceMapDebugResponseFrame ) { let releaseProgress = 0; if (sourceMapDebuggerData.release !== null) { @@ -129,7 +129,7 @@ function getReleaseProgress( return {releaseProgress, releaseProgressPercent: releaseProgress / 4}; } -function getScrapingProgress(debuggerFrame: SourceMapDebugBlueThunderResponseFrame) { +function getScrapingProgress(debuggerFrame: SourceMapDebugResponseFrame) { let scrapingProgress = 0; if (debuggerFrame.scraping_process?.source_file?.status === 'success') { @@ -148,8 +148,8 @@ function getScrapingProgress(debuggerFrame: SourceMapDebugBlueThunderResponseFra } export function prepareSourceMapDebuggerFrameInformation( - sourceMapDebuggerData: SourceMapDebugBlueThunderResponse, - debuggerFrame: SourceMapDebugBlueThunderResponseFrame, + sourceMapDebuggerData: SourceMapDebugResponse, + debuggerFrame: SourceMapDebugResponseFrame, event: Event, projectPlatform: PlatformKey | undefined ): FrameSourceMapDebuggerData { diff --git a/static/app/components/stackTrace/stackTrace.spec.tsx b/static/app/components/stackTrace/stackTrace.spec.tsx index e099033f4f329a..5fcfd8d45a158c 100644 --- a/static/app/components/stackTrace/stackTrace.spec.tsx +++ b/static/app/components/stackTrace/stackTrace.spec.tsx @@ -485,7 +485,7 @@ describe('Core StackTrace', () => { const frame = stacktrace.frames[stacktrace.frames.length - 1]!; ProjectsStore.loadInitialData([project]); MockApiClient.addMockResponse({ - url: `/projects/${organization.slug}/${project.slug}/events/${javascriptEvent.id}/source-map-debug-blue-thunder-edition/`, + url: `/projects/${organization.slug}/${project.slug}/events/${javascriptEvent.id}/source-map-debug/`, body: { dist: null, exceptions: [ diff --git a/static/app/components/workflowEngine/form/automationBuilderSelect.tsx b/static/app/components/workflowEngine/form/automationBuilderSelect.tsx index 25f94a6cca97f3..27e35bd6d33577 100644 --- a/static/app/components/workflowEngine/form/automationBuilderSelect.tsx +++ b/static/app/components/workflowEngine/form/automationBuilderSelect.tsx @@ -8,7 +8,7 @@ export function AutomationBuilderSelect(props: ComponentProps) { } const StyledSelect = styled(Select)` - width: 180px; + min-width: 180px; padding: 0; > div { padding-left: 0; @@ -20,7 +20,6 @@ export const selectControlStyles = { control: (provided: any) => ({ ...provided, minHeight: '32px', - height: '32px', padding: 0, }), }; diff --git a/static/app/types/workflowEngine/dataConditions.tsx b/static/app/types/workflowEngine/dataConditions.tsx index 6f0fb1b4930b03..deb78ce034df56 100644 --- a/static/app/types/workflowEngine/dataConditions.tsx +++ b/static/app/types/workflowEngine/dataConditions.tsx @@ -53,6 +53,9 @@ export enum DataConditionType { EVENT_UNIQUE_USER_FREQUENCY = 'event_unique_user_frequency', PERCENT_SESSIONS = 'percent_sessions', ANOMALY_DETECTION = 'anomaly_detection', + + // activity trigger conditions + SEER_ACTIVITY_TRIGGER = 'seer_activity_trigger', } export enum DataConditionGroupLogicType { 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/automations/components/actionFilters/seerActivityTrigger.spec.tsx b/static/app/views/automations/components/actionFilters/seerActivityTrigger.spec.tsx new file mode 100644 index 00000000000000..0c4da459502b03 --- /dev/null +++ b/static/app/views/automations/components/actionFilters/seerActivityTrigger.spec.tsx @@ -0,0 +1,161 @@ +import {DataConditionFixture} from 'sentry-fixture/automations'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {DataConditionType} from 'sentry/types/workflowEngine/dataConditions'; +import { + SeerActivityTriggerDetails, + SeerActivityTriggerNode, + validateSeerActivityTriggerCondition, +} from 'sentry/views/automations/components/actionFilters/seerActivityTrigger'; +import {AutomationBuilderErrorContext} from 'sentry/views/automations/components/automationBuilderErrorContext'; +import {DataConditionNodeContext} from 'sentry/views/automations/components/dataConditionNodes'; + +describe('SeerActivityTriggerDetails', () => { + it('renders single-stage text', () => { + render( + + ); + + expect( + screen.getByText("Seer reaches the 'Pull request created' stage") + ).toBeInTheDocument(); + }); + + it('renders multi-stage text', () => { + render( + + ); + + expect( + screen.getByText( + 'Seer reaches any of these stages: Root cause analysis started, Coding completed' + ) + ).toBeInTheDocument(); + }); + + it('handles empty comparison gracefully', () => { + render( + + ); + + expect(screen.getByText('Seer reaches any of these stages:')).toBeInTheDocument(); + }); +}); + +describe('SeerActivityTriggerNode', () => { + const dataCondition = DataConditionFixture({ + id: 'seer-1', + type: DataConditionType.SEER_ACTIVITY_TRIGGER, + comparison: ['rca_started', 'coding_completed'], + }); + const errorContext = { + errors: {}, + mutationErrors: undefined, + setErrors: jest.fn(), + removeError: jest.fn(), + }; + const dataConditionNodeContext = { + condition: dataCondition, + condition_id: dataCondition.id, + onUpdate: jest.fn(), + }; + + it('renders the label and select', () => { + render( + + + + + + ); + + expect( + screen.getByText('Seer runs on an issue and reaches the stage...') + ).toBeInTheDocument(); + expect( + screen.getByRole('textbox', {name: 'Seer activity stages'}) + ).toBeInTheDocument(); + }); + + it('calls onUpdate and removeError when a stage is selected', async () => { + render( + + + + + + ); + await userEvent.click(screen.getByRole('textbox', {name: 'Seer activity stages'})); + await userEvent.click( + screen.getByRole('menuitemcheckbox', {name: 'Pull request created'}) + ); + await waitFor(() => { + expect(dataConditionNodeContext.onUpdate).toHaveBeenCalledWith({ + comparison: dataCondition.comparison.concat('pr_created'), + }); + }); + expect(errorContext.removeError).toHaveBeenCalledWith('seer-1'); + }); + + it('renders pre-selected stages', () => { + render( + + + + + + ); + + expect(screen.getByText('Root cause analysis started')).toBeInTheDocument(); + expect(screen.getByText('Coding completed')).toBeInTheDocument(); + }); +}); + +describe('validateSeerActivityTriggerCondition', () => { + it('returns error when comparison is invalid', () => { + expect( + validateSeerActivityTriggerCondition({ + condition: DataConditionFixture({ + type: DataConditionType.SEER_ACTIVITY_TRIGGER, + comparison: [], + }), + }) + ).toBe('You must select at least one Seer stage.'); + + expect( + validateSeerActivityTriggerCondition({ + condition: DataConditionFixture({ + type: DataConditionType.SEER_ACTIVITY_TRIGGER, + comparison: undefined, + }), + }) + ).toBe('You must select at least one Seer stage.'); + }); + + it('returns undefined for valid comparison', () => { + expect( + validateSeerActivityTriggerCondition({ + condition: DataConditionFixture({ + type: DataConditionType.SEER_ACTIVITY_TRIGGER, + comparison: ['rca_started'], + }), + }) + ).toBeUndefined(); + }); +}); diff --git a/static/app/views/automations/components/actionFilters/seerActivityTrigger.tsx b/static/app/views/automations/components/actionFilters/seerActivityTrigger.tsx new file mode 100644 index 00000000000000..d90e86b133afd3 --- /dev/null +++ b/static/app/views/automations/components/actionFilters/seerActivityTrigger.tsx @@ -0,0 +1,70 @@ +import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {AutomationBuilderSelect} from 'sentry/components/workflowEngine/form/automationBuilderSelect'; +import {t, tct} from 'sentry/locale'; +import type {SelectValue} from 'sentry/types/core'; +import type {DataCondition} from 'sentry/types/workflowEngine/dataConditions'; +import {useAutomationBuilderErrorContext} from 'sentry/views/automations/components/automationBuilderErrorContext'; +import type {ValidateDataConditionProps} from 'sentry/views/automations/components/automationFormData'; +import {useDataConditionNodeContext} from 'sentry/views/automations/components/dataConditionNodes'; + +const SEER_ACTIVITY_STAGE_CHOICES: Array<{label: string; value: string}> = [ + {value: 'rca_started', label: t('Root cause analysis started')}, + {value: 'rca_completed', label: t('Root cause analysis completed')}, + {value: 'solution_started', label: t('Solution search started')}, + {value: 'solution_completed', label: t('Solution search completed')}, + {value: 'coding_started', label: t('Coding started')}, + {value: 'coding_completed', label: t('Coding completed')}, + {value: 'pr_created', label: t('Pull request created')}, +]; + +export function SeerActivityTriggerDetails({condition}: {condition: DataCondition}) { + const stages: string[] = Array.isArray(condition.comparison) + ? condition.comparison + : []; + // The stages should all appear in SEER_ACTIVITY_STAGE_CHOICES, but for type safety we call + // call .filter(Boolean) to get rid of invalid stages when we render. + const labels = stages + .map(s => SEER_ACTIVITY_STAGE_CHOICES.find(c => c.value === s)?.label) + .filter(Boolean); + const details = + labels.length === 1 + ? tct("Seer reaches the '[stage]' stage", {stage: labels[0] ?? ''}) + : tct('Seer reaches any of these stages: [stages]', {stages: labels.join(', ')}); + + return {details}; +} + +export function SeerActivityTriggerNode() { + const {condition, condition_id, onUpdate} = useDataConditionNodeContext(); + const {removeError} = useAutomationBuilderErrorContext(); + const value: string[] = Array.isArray(condition.comparison) ? condition.comparison : []; + + return ( + + {t('Seer runs on an issue and reaches the stage...')} + >) => { + onUpdate({comparison: options.map(o => o.value)}); + removeError(condition.id); + }} + /> + + ); +} + +export function validateSeerActivityTriggerCondition({ + condition, +}: ValidateDataConditionProps): string | undefined { + if (!Array.isArray(condition.comparison) || condition.comparison.length === 0) { + return t('You must select at least one Seer stage.'); + } + return undefined; +} diff --git a/static/app/views/automations/components/automationBuilderContext.spec.tsx b/static/app/views/automations/components/automationBuilderContext.spec.tsx new file mode 100644 index 00000000000000..3acdec87358bad --- /dev/null +++ b/static/app/views/automations/components/automationBuilderContext.spec.tsx @@ -0,0 +1,64 @@ +import {act, renderHook} from 'sentry-test/reactTestingLibrary'; + +import {DataConditionType} from 'sentry/types/workflowEngine/dataConditions'; +import {dataConditionNodesMap} from 'sentry/views/automations/components/dataConditionNodes'; + +import {useAutomationBuilderReducer} from './automationBuilderContext'; + +describe('useAutomationBuilderReducer', () => { + it('falls back to true for when conditions without a defaultComparison', () => { + const {result} = renderHook(useAutomationBuilderReducer); + + act(() => { + result.current.actions.addWhenCondition(DataConditionType.FIRST_SEEN_EVENT); + }); + + const conditions = result.current.state.triggers.conditions.filter( + c => c.type === DataConditionType.FIRST_SEEN_EVENT + ); + const addedCondition = conditions.at(-1); + + expect(addedCondition?.comparison).toBe(true); + }); + + it('uses defaultComparison from the node map when adding a when condition', () => { + const {result} = renderHook(useAutomationBuilderReducer); + + act(() => { + result.current.actions.addWhenCondition(DataConditionType.SEER_ACTIVITY_TRIGGER); + }); + + const addedCondition = result.current.state.triggers.conditions.find( + c => c.type === DataConditionType.SEER_ACTIVITY_TRIGGER + ); + + const expectedDefault = dataConditionNodesMap.get( + DataConditionType.SEER_ACTIVITY_TRIGGER + )?.defaultComparison; + + expect(expectedDefault).toBeDefined(); + expect(addedCondition).toBeDefined(); + expect(addedCondition?.comparison).toEqual(expectedDefault); + }); + + it('uses defaultComparison from the node map when adding an if condition', () => { + const {result} = renderHook(useAutomationBuilderReducer); + const groupId = result.current.state.actionFilters[0]!.id; + + act(() => { + result.current.actions.addIfCondition(groupId, DataConditionType.AGE_COMPARISON); + }); + + const addedCondition = result.current.state.actionFilters[0]?.conditions.find( + c => c.type === DataConditionType.AGE_COMPARISON + ); + + const expectedDefault = dataConditionNodesMap.get( + DataConditionType.AGE_COMPARISON + )?.defaultComparison; + + expect(expectedDefault).toBeDefined(); + expect(addedCondition).toBeDefined(); + expect(addedCondition?.comparison).toEqual(expectedDefault); + }); +}); diff --git a/static/app/views/automations/components/automationBuilderContext.tsx b/static/app/views/automations/components/automationBuilderContext.tsx index e786cdf776a65a..c01690402419b8 100644 --- a/static/app/views/automations/components/automationBuilderContext.tsx +++ b/static/app/views/automations/components/automationBuilderContext.tsx @@ -186,10 +186,10 @@ const initialAutomationBuilderState: AutomationBuilderState = { id: 'when', logicType: DataConditionGroupLogicType.ANY_SHORT_CIRCUIT, conditions: [ - createWhenCondition(DataConditionType.FIRST_SEEN_EVENT), - createWhenCondition(DataConditionType.ISSUE_RESOLVED_TRIGGER), - createWhenCondition(DataConditionType.REAPPEARED_EVENT), - createWhenCondition(DataConditionType.REGRESSION_EVENT), + createCondition(DataConditionType.FIRST_SEEN_EVENT), + createCondition(DataConditionType.ISSUE_RESOLVED_TRIGGER), + createCondition(DataConditionType.REAPPEARED_EVENT), + createCondition(DataConditionType.REGRESSION_EVENT), ], }, actionFilters: [ @@ -297,15 +297,17 @@ type AutomationBuilderAction = | UpdateIfActionAction | UpdateIfLogicTypeAction; -function createWhenCondition(conditionType: DataConditionType): DataCondition { +function createCondition(conditionType: DataConditionType): DataCondition { return { id: uuid4(), type: conditionType, - comparison: true, + // Most when conditions are booleans, so it's a safe fallback. + // If a condition's comparison is not a boolean, it MUST set a default comparison. + // For example, see DataConditionType.SEER_ACTIVITY_TRIGGER. + comparison: dataConditionNodesMap.get(conditionType)?.defaultComparison ?? true, conditionResult: true, }; } - function addWhenCondition( state: AutomationBuilderState, action: AddWhenConditionAction @@ -314,10 +316,7 @@ function addWhenCondition( ...state, triggers: { ...state.triggers, - conditions: [ - ...state.triggers.conditions, - createWhenCondition(action.conditionType), - ], + conditions: [...state.triggers.conditions, createCondition(action.conditionType)], }, }; } @@ -409,16 +408,7 @@ function addIfCondition( } return { ...group, - conditions: [ - ...group.conditions, - { - id: uuid4(), - type: conditionType, - comparison: - dataConditionNodesMap.get(conditionType)?.defaultComparison || true, - conditionResult: true, - }, - ], + conditions: [...group.conditions, createCondition(conditionType)], }; }), }; diff --git a/static/app/views/automations/components/dataConditionNodes.tsx b/static/app/views/automations/components/dataConditionNodes.tsx index 2d31cbeb31a317..8ccbf8b87a9bb1 100644 --- a/static/app/views/automations/components/dataConditionNodes.tsx +++ b/static/app/views/automations/components/dataConditionNodes.tsx @@ -85,6 +85,11 @@ import { PercentSessionsPercentDetails, validatePercentSessionsCondition, } from 'sentry/views/automations/components/actionFilters/percentSessions'; +import { + SeerActivityTriggerDetails, + SeerActivityTriggerNode, + validateSeerActivityTriggerCondition, +} from 'sentry/views/automations/components/actionFilters/seerActivityTrigger'; import { TaggedEventDetails, TaggedEventNode, @@ -164,6 +169,16 @@ export const dataConditionNodesMap = new Map { const organization = OrganizationFixture(); - const apiUrl = `/projects/${organization.slug}/${PROJECT_SLUG}/events/${EVENT_ID}/source-map-debug-blue-thunder-edition/`; + const apiUrl = `/projects/${organization.slug}/${PROJECT_SLUG}/events/${EVENT_ID}/source-map-debug/`; it('shows error state when the API call fails', async () => { MockApiClient.addMockResponse({url: apiUrl, statusCode: 500, body: {}}); diff --git a/static/app/views/issueDetails/configurationIssues/sourceMapIssues/diagnosisSection.tsx b/static/app/views/issueDetails/configurationIssues/sourceMapIssues/diagnosisSection.tsx index b70402d33fa9a6..1a32233962a42b 100644 --- a/static/app/views/issueDetails/configurationIssues/sourceMapIssues/diagnosisSection.tsx +++ b/static/app/views/issueDetails/configurationIssues/sourceMapIssues/diagnosisSection.tsx @@ -6,7 +6,7 @@ import {Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; import type { - SourceMapDebugBlueThunderResponse, + SourceMapDebugResponse, SourceMapDebugQueryResult, } from 'sentry/components/events/interfaces/crashContent/exception/useSourceMapDebuggerData'; import {LoadingError} from 'sentry/components/loadingError'; @@ -14,9 +14,7 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {IconOpen} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; -function getDiagnosisMessage( - data: SourceMapDebugBlueThunderResponse | undefined -): ReactNode | null { +function getDiagnosisMessage(data: SourceMapDebugResponse | undefined): ReactNode | null { if (!data) { return ( {t('Unable to load source map diagnostic information for this event.')} 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')} + + + )} + + )} + +
+ + ); + } + + 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 - )} - - - - - ) : ( - - - - )} + { - 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; 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).')} + + ); +} 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)` 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} diff --git a/tests/js/fixtures/sourceMapDebug.ts b/tests/js/fixtures/sourceMapDebug.ts index 1f4793d74e3250..4a7475ae194ae3 100644 --- a/tests/js/fixtures/sourceMapDebug.ts +++ b/tests/js/fixtures/sourceMapDebug.ts @@ -1,11 +1,9 @@ import type { - SourceMapDebugBlueThunderResponse, - SourceMapDebugBlueThunderResponseFrame, + SourceMapDebugResponse, + SourceMapDebugResponseFrame, } from 'sentry/components/events/interfaces/crashContent/exception/useSourceMapDebuggerData'; -type ReleaseProcess = NonNullable< - SourceMapDebugBlueThunderResponseFrame['release_process'] ->; +type ReleaseProcess = NonNullable; export function SourceMapDebugReleaseProcessFixture( params: Partial = {} @@ -22,8 +20,8 @@ export function SourceMapDebugReleaseProcessFixture( } export function SourceMapDebugFrameFixture( - params: Partial = {} -): SourceMapDebugBlueThunderResponseFrame { + params: Partial = {} +): SourceMapDebugResponseFrame { return { debug_id_process: { debug_id: null, @@ -37,8 +35,8 @@ export function SourceMapDebugFrameFixture( } export function SourceMapDebugResponseFixture( - params: Partial = {} -): SourceMapDebugBlueThunderResponse { + params: Partial = {} +): SourceMapDebugResponse { return { dist: null, release: null, 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): 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/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/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/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 diff --git a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py index 06f2a7a95a4161..87f52486e46287 100644 --- a/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py +++ b/tests/sentry/integrations/github/tasks/test_sync_repos_on_install_change.py @@ -14,8 +14,6 @@ from sentry.testutils.cases import IntegrationTestCase from sentry.testutils.silo import assume_test_silo_mode, assume_test_silo_mode_of, control_silo_test -FEATURE_FLAG = "organizations:github-repo-auto-sync-webhook" - @control_silo_test @patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1") @@ -36,14 +34,13 @@ def _make_repos_removed(self): ] def test_repos_added(self, _: MagicMock) -> None: - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=[], - repository_selection="selected", - ) + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) with assume_test_silo_mode(SiloMode.CELL): repos = Repository.objects.filter(organization_id=self.organization.id).order_by("name") @@ -72,14 +69,13 @@ def test_repos_removed(self, _: MagicMock) -> None: status=ObjectStatus.ACTIVE, ) - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="removed", - repos_added=[], - repos_removed=self._make_repos_removed(), - repository_selection="selected", - ) + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) with assume_test_silo_mode(SiloMode.CELL): repo.refresh_from_db() @@ -102,14 +98,13 @@ def test_mixed_add_and_remove(self, _: MagicMock) -> None: status=ObjectStatus.ACTIVE, ) - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=self._make_repos_removed(), - repository_selection="selected", - ) + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) with assume_test_silo_mode(SiloMode.CELL): old_repo.refresh_from_db() @@ -130,14 +125,13 @@ def test_multi_org(self, _: MagicMock) -> None: integration=self.integration, ) - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=[], - repository_selection="selected", - ) + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) with assume_test_silo_mode(SiloMode.CELL): repos_org1 = Repository.objects.filter(organization_id=self.organization.id) @@ -172,11 +166,11 @@ def test_inactive_integration(self, _: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): assert Repository.objects.count() == 0 - def test_feature_flag_off(self, _: MagicMock) -> None: + def test_empty_repos_is_noop(self, _: MagicMock) -> None: sync_repos_on_install_change( integration_id=self.integration.id, action="added", - repos_added=self._make_repos_added(), + repos_added=[], repos_removed=[], repository_selection="selected", ) @@ -184,19 +178,6 @@ def test_feature_flag_off(self, _: MagicMock) -> None: with assume_test_silo_mode(SiloMode.CELL): assert Repository.objects.count() == 0 - def test_empty_repos_is_noop(self, _: MagicMock) -> None: - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=[], - repos_removed=[], - repository_selection="selected", - ) - - with assume_test_silo_mode(SiloMode.CELL): - assert Repository.objects.count() == 0 - def test_stamps_last_sync_on_org_integration(self, _: MagicMock) -> None: oi = OrganizationIntegration.objects.get( organization_id=self.organization.id, integration=self.integration @@ -207,14 +188,13 @@ def test_stamps_last_sync_on_org_integration(self, _: MagicMock) -> None: } oi.save() - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=self._make_repos_added(), - repos_removed=[], - repository_selection="selected", - ) + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=self._make_repos_added(), + repos_removed=[], + repository_selection="selected", + ) oi.refresh_from_db() assert oi.config["last_sync"] > "2020-01-01T00:00:00+00:00" @@ -230,14 +210,13 @@ def test_does_not_stamp_last_repos_change_when_no_diff(self, _: MagicMock) -> No } oi.save() - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="added", - repos_added=[], - repos_removed=[], - repository_selection="selected", - ) + sync_repos_on_install_change( + integration_id=self.integration.id, + action="added", + repos_added=[], + repos_removed=[], + repository_selection="selected", + ) oi.refresh_from_db() assert oi.config["last_sync"] > "2020-01-01T00:00:00+00:00" @@ -259,14 +238,13 @@ def test_skips_disable_for_repo_with_recent_activity(self, _: MagicMock) -> None key="abc123", ) - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="removed", - repos_added=[], - repos_removed=self._make_repos_removed(), - repository_selection="selected", - ) + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) with assume_test_silo_mode(SiloMode.CELL): repo.refresh_from_db() @@ -283,14 +261,13 @@ def test_does_not_disable_already_disabled_repos(self, _: MagicMock) -> None: status=ObjectStatus.DISABLED, ) - with self.feature(FEATURE_FLAG): - sync_repos_on_install_change( - integration_id=self.integration.id, - action="removed", - repos_added=[], - repos_removed=self._make_repos_removed(), - repository_selection="selected", - ) + sync_repos_on_install_change( + integration_id=self.integration.id, + action="removed", + repos_added=[], + repos_removed=self._make_repos_removed(), + repository_selection="selected", + ) with assume_test_silo_mode(SiloMode.CELL): repo.refresh_from_db() diff --git a/tests/sentry/integrations/github/test_webhook.py b/tests/sentry/integrations/github/test_webhook.py index ee489538a9c0d6..101d0f133eb993 100644 --- a/tests/sentry/integrations/github/test_webhook.py +++ b/tests/sentry/integrations/github/test_webhook.py @@ -352,7 +352,7 @@ def test_end_to_end_repos_added(self) -> None: ) sha1, sha256 = self._compute_signatures(body) - with self.feature("organizations:github-repo-auto-sync-webhook"), self.tasks(): + with self.tasks(): response = self.client.post( path=self.url, data=body, @@ -399,7 +399,7 @@ def test_end_to_end_repos_removed(self) -> None: ) sha1, sha256 = self._compute_signatures(body) - with self.feature("organizations:github-repo-auto-sync-webhook"), self.tasks(): + with self.tasks(): response = self.client.post( path=self.url, data=body, diff --git a/tests/sentry/integrations/source_code_management/test_auto_link_repos.py b/tests/sentry/integrations/source_code_management/test_auto_link_repos.py index 6ddf66b129d2b3..a3ad41e41ce85f 100644 --- a/tests/sentry/integrations/source_code_management/test_auto_link_repos.py +++ b/tests/sentry/integrations/source_code_management/test_auto_link_repos.py @@ -1,6 +1,7 @@ from sentry.constants import ObjectStatus from sentry.integrations.source_code_management.auto_link_repos import ( auto_link_repos_by_name, + auto_link_repos_on_project_create, get_repo_name_candidates, ) from sentry.models.projectrepository import ProjectRepository, ProjectRepositorySource @@ -219,3 +220,68 @@ def test_idempotent(self) -> None: ProjectRepository.objects.filter(project=self.project, repository=self.repo).count() == 1 ) + + +class AutoLinkReposOnProjectCreateTest(TestCase): + def test_links_matching_repo_on_project_create(self) -> None: + org = self.create_organization() + Repository.objects.create( + organization_id=org.id, + name="getsentry/sentry", + provider="integrations:github", + external_id="123", + ) + project = self.create_project(organization=org, slug="sentry") + + with ( + self.feature("organizations:auto-link-repos-by-name"), + self.options({"repository.auto-link-by-name-dry-run": False}), + ): + auto_link_repos_on_project_create(project) + + assert ProjectRepository.objects.filter( + project=project, + source=ProjectRepositorySource.AUTO_NAME_MATCH, + ).exists() + + def test_skips_when_no_matching_repo(self) -> None: + org = self.create_organization() + Repository.objects.create( + organization_id=org.id, + name="getsentry/relay", + provider="integrations:github", + external_id="456", + ) + project = self.create_project(organization=org, slug="sentry") + + with ( + self.feature("organizations:auto-link-repos-by-name"), + self.options({"repository.auto-link-by-name-dry-run": False}), + ): + auto_link_repos_on_project_create(project) + + assert not ProjectRepository.objects.filter(project=project).exists() + + def test_skips_already_linked_repos(self) -> None: + org = self.create_organization() + repo = Repository.objects.create( + organization_id=org.id, + name="getsentry/sentry", + provider="integrations:github", + external_id="123", + ) + other_project = self.create_project(organization=org, slug="other") + ProjectRepository.objects.create( + project=other_project, + repository=repo, + source=ProjectRepositorySource.MANUAL, + ) + project = self.create_project(organization=org, slug="sentry") + + with ( + self.feature("organizations:auto-link-repos-by-name"), + self.options({"repository.auto-link-by-name-dry-run": False}), + ): + auto_link_repos_on_project_create(project) + + assert not ProjectRepository.objects.filter(project=project).exists() 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() diff --git a/tests/sentry/preprod/snapshots/test_compare_snapshots.py b/tests/sentry/preprod/snapshots/test_compare_snapshots.py new file mode 100644 index 00000000000000..b0ff655184e3bb --- /dev/null +++ b/tests/sentry/preprod/snapshots/test_compare_snapshots.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import orjson +import pytest +from objectstore_client.client import RequestError + +from sentry.preprod.models import PreprodArtifact, PreprodSnapshotComparison +from sentry.preprod.snapshots.image_diff.types import DiffResult +from sentry.preprod.snapshots.tasks import _retry_objectstore, compare_snapshots +from sentry.testutils.cases import TestCase + +TASKS = "sentry.preprod.snapshots.tasks" + + +class RetryObjectstoreTest(TestCase): + def test_retries_once_then_succeeds_on_429(self) -> None: + calls = [] + + def op() -> str: + calls.append(1) + if len(calls) < 2: + raise RequestError("rate limited", status=429, response="") + return "ok" + + with patch("sentry.preprod.snapshots.tasks.time.sleep"): + assert _retry_objectstore(op) == "ok" + assert len(calls) == 2 + + def test_gives_up_after_one_retry(self) -> None: + calls = [] + + def op() -> str: + calls.append(1) + raise RequestError("rate limited", status=429, response="") + + with patch("sentry.preprod.snapshots.tasks.time.sleep"), pytest.raises(RequestError): + _retry_objectstore(op) + assert len(calls) == 2 + + def test_does_not_retry_non_transient_status(self) -> None: + calls = [] + + def op() -> str: + calls.append(1) + raise RequestError("not found", status=404, response="") + + with pytest.raises(RequestError): + _retry_objectstore(op) + assert len(calls) == 1 + + +def _manifest_bytes(images: dict[str, str]) -> bytes: + return orjson.dumps( + { + "images": { + name: {"content_hash": h, "width": 10, "height": 10} for name, h in images.items() + }, + "selective": False, + "all_image_file_names": None, + } + ) + + +def _diff_result(changed_pixels: int) -> DiffResult: + return DiffResult( + diff_mask_png=b"\x89PNGfake", + changed_pixels=changed_pixels, + total_pixels=100, + aligned_height=10, + before_width=10, + before_height=10, + after_width=10, + after_height=10, + ) + + +class CompareSnapshotsTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.head_artifact = self.create_preprod_artifact( + project=self.project, state=PreprodArtifact.ArtifactState.PROCESSED + ) + self.base_artifact = self.create_preprod_artifact( + project=self.project, state=PreprodArtifact.ArtifactState.PROCESSED + ) + self.head_key = ( + f"{self.organization.id}/{self.project.id}/{self.head_artifact.id}/manifest.json" + ) + self.base_key = ( + f"{self.organization.id}/{self.project.id}/{self.base_artifact.id}/manifest.json" + ) + # The factory does not accept extras=; set manifest_key explicitly after creation. + self.head_metrics = self.create_preprod_snapshot_metrics(self.head_artifact) + self.head_metrics.extras = {"manifest_key": self.head_key} + self.head_metrics.save() + self.base_metrics = self.create_preprod_snapshot_metrics(self.base_artifact) + self.base_metrics.extras = {"manifest_key": self.base_key} + self.base_metrics.save() + + def _make_session(self, head_images: dict[str, str], base_images: dict[str, str]) -> MagicMock: + manifests = { + self.head_key: _manifest_bytes(head_images), + self.base_key: _manifest_bytes(base_images), + } + + def _get(key: str) -> MagicMock: + result = MagicMock() + if key in manifests: + result.payload.read.return_value = manifests[key] + else: # image fetch: f"{org}/{project}/{hash}" + result.payload.read.return_value = b"imgbytes" + return result + + session = MagicMock() + session.get.side_effect = _get + return session + + def _run(self, session: MagicMock, diff_results: list[DiffResult | None]) -> None: + with ( + patch(f"{TASKS}.get_preprod_session", return_value=session), + patch(f"{TASKS}.OdiffServer"), + patch(f"{TASKS}.compare_images_batch", return_value=diff_results), + patch(f"{TASKS}.update_preprod_snapshot_vcs"), + patch(f"{TASKS}._try_auto_approve_snapshot"), + ): + compare_snapshots( + project_id=self.project.id, + org_id=self.organization.id, + head_artifact_id=self.head_artifact.id, + base_artifact_id=self.base_artifact.id, + ) + + def test_compare_snapshots_success_serial(self) -> None: + session = self._make_session( + head_images={"a.png": "h1", "b.png": "same"}, + base_images={"a.png": "h0", "b.png": "same"}, + ) + self._run(session, diff_results=[_diff_result(changed_pixels=50)]) + + comparison = PreprodSnapshotComparison.objects.get( + head_snapshot_metrics=self.head_metrics, base_snapshot_metrics=self.base_metrics + ) + assert comparison.state == PreprodSnapshotComparison.State.SUCCESS + assert comparison.images_changed == 1 + assert comparison.images_unchanged == 1 + put_keys = [c.kwargs.get("key") for c in session.put.call_args_list] + assert any("/diff/" in (k or "") for k in put_keys) + assert any(k and k.endswith("comparison.json") for k in put_keys) + + def test_compare_snapshots_spawns_n_servers_for_many_batches(self) -> None: + session = self._make_session( + head_images={f"img{i}.png": f"h{i}a" for i in range(6)}, + base_images={f"img{i}.png": f"h{i}b" for i in range(6)}, + ) + with ( + patch(f"{TASKS}.get_preprod_session", return_value=session), + patch(f"{TASKS}.OdiffServer") as mock_server, + patch( + f"{TASKS}.compare_images_batch", + side_effect=lambda pairs, server=None: [_diff_result(5) for _ in pairs], + ), + patch(f"{TASKS}.update_preprod_snapshot_vcs"), + patch(f"{TASKS}._try_auto_approve_snapshot"), + patch(f"{TASKS}.MAX_PIXELS_PER_BATCH", 100), + self.options({"preprod.snapshots.odiff-worker-count": 3}), + ): + compare_snapshots( + project_id=self.project.id, + org_id=self.organization.id, + head_artifact_id=self.head_artifact.id, + base_artifact_id=self.base_artifact.id, + ) + # 6 images at 100px each -> 1 pair per batch -> 6 batches; min(3, 6) == 3 servers + assert mock_server.call_count == 3 + comparison = PreprodSnapshotComparison.objects.get( + head_snapshot_metrics=self.head_metrics, base_snapshot_metrics=self.base_metrics + ) + assert comparison.state == PreprodSnapshotComparison.State.SUCCESS + assert comparison.images_changed == 6 + + def test_compare_snapshots_logs_odiff_phase_summary(self) -> None: + session = self._make_session( + head_images={"a.png": "h1", "b.png": "same"}, + base_images={"a.png": "h0", "b.png": "same"}, + ) + with ( + patch(f"{TASKS}.get_preprod_session", return_value=session), + patch(f"{TASKS}.OdiffServer"), + patch(f"{TASKS}.compare_images_batch", return_value=[_diff_result(50)]), + patch(f"{TASKS}.update_preprod_snapshot_vcs"), + patch(f"{TASKS}._try_auto_approve_snapshot"), + patch(f"{TASKS}.logger") as mock_logger, + ): + compare_snapshots( + project_id=self.project.id, + org_id=self.organization.id, + head_artifact_id=self.head_artifact.id, + base_artifact_id=self.base_artifact.id, + ) + messages = [c.args[0] for c in mock_logger.info.call_args_list] + assert "compare_snapshots: odiff phase complete" in messages + + def test_compare_snapshots_batch_failure_is_isolated(self) -> None: + session = self._make_session( + head_images={"a.png": "h1"}, + base_images={"a.png": "h0"}, + ) + + def _put(data, key=None, content_type=None): + if "/diff/" in (key or ""): + raise RuntimeError("mask upload failed") + return MagicMock() + + session.put.side_effect = _put + with ( + patch(f"{TASKS}.get_preprod_session", return_value=session), + patch(f"{TASKS}.OdiffServer"), + patch(f"{TASKS}.compare_images_batch", return_value=[_diff_result(5)]), + patch(f"{TASKS}.update_preprod_snapshot_vcs"), + patch(f"{TASKS}._try_auto_approve_snapshot"), + ): + compare_snapshots( + project_id=self.project.id, + org_id=self.organization.id, + head_artifact_id=self.head_artifact.id, + base_artifact_id=self.base_artifact.id, + ) + + comparison = PreprodSnapshotComparison.objects.get( + head_snapshot_metrics=self.head_metrics, base_snapshot_metrics=self.base_metrics + ) + assert comparison.state == PreprodSnapshotComparison.State.SUCCESS + assert comparison.images_changed == 0 + comparison_json = next( + c.args[0] + for c in session.put.call_args_list + if (c.kwargs.get("key") or "").endswith("comparison.json") + ) + images = orjson.loads(comparison_json)["images"] + assert images["a.png"]["status"] == "errored" + + def test_compare_snapshots_per_image_processing_failure(self) -> None: + session = self._make_session( + head_images={"a.png": "h1", "b.png": "h3"}, + base_images={"a.png": "h0", "b.png": "h2"}, + ) + # both pairs change; odiff yields a result for a.png and None (failed) for b.png + self._run(session, diff_results=[_diff_result(50), None]) + + comparison = PreprodSnapshotComparison.objects.get( + head_snapshot_metrics=self.head_metrics, base_snapshot_metrics=self.base_metrics + ) + assert comparison.state == PreprodSnapshotComparison.State.SUCCESS + assert comparison.images_changed == 1 + comparison_json = next( + c.args[0] + for c in session.put.call_args_list + if (c.kwargs.get("key") or "").endswith("comparison.json") + ) + images = orjson.loads(comparison_json)["images"] + assert images["a.png"]["status"] == "changed" + assert images["b.png"]["status"] == "errored" + assert images["b.png"]["reason"] == "image_processing_failed" + + def test_compare_snapshots_retries_rate_limited_manifest(self) -> None: + session = self._make_session( + head_images={"a.png": "h1"}, + base_images={"a.png": "h0"}, + ) + underlying_get = session.get.side_effect + state = {"throttled": False} + + def flaky_get(key: str) -> MagicMock: + if key == self.head_key and not state["throttled"]: + state["throttled"] = True + raise RequestError("rate limited", status=429, response="") + return underlying_get(key) + + session.get.side_effect = flaky_get + with patch("sentry.preprod.snapshots.tasks.time.sleep"): + self._run(session, diff_results=[_diff_result(50)]) + + assert state["throttled"] # the 429 was hit and retried, not fatal + comparison = PreprodSnapshotComparison.objects.get( + head_snapshot_metrics=self.head_metrics, base_snapshot_metrics=self.base_metrics + ) + assert comparison.state == PreprodSnapshotComparison.State.SUCCESS 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() 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() 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) 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 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, diff --git a/tests/sentry/workflow_engine/endpoints/test_organization_data_condition_index.py b/tests/sentry/workflow_engine/endpoints/test_organization_data_condition_index.py index 195787624bc8f0..033103b8b187f6 100644 --- a/tests/sentry/workflow_engine/endpoints/test_organization_data_condition_index.py +++ b/tests/sentry/workflow_engine/endpoints/test_organization_data_condition_index.py @@ -3,6 +3,7 @@ from unittest.mock import patch from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.features import with_feature from sentry.testutils.silo import cell_silo_test from sentry.utils.registry import Registry from sentry.workflow_engine.models.data_condition import Condition @@ -128,3 +129,34 @@ def test_invalid_group(self) -> None: def test_no_group(self) -> None: self.get_error_response(self.organization.slug, status_code=400) + + def test_seer_activity_trigger_hidden_without_feature(self) -> None: + @self.registry.register(Condition.SEER_ACTIVITY_TRIGGER) + @dataclass(frozen=True) + class TestSeerActivityTrigger(DataConditionHandler[dict[str, str]]): + group = DataConditionHandler.Group.WORKFLOW_TRIGGER + comparison_json_schema = {"type": "array", "items": {"type": "string"}} + + response = self.get_success_response( + self.organization.slug, + group=DataConditionHandler.Group.WORKFLOW_TRIGGER, + status_code=200, + ) + condition_types = [item["type"] for item in response.data] + assert Condition.SEER_ACTIVITY_TRIGGER.value not in condition_types + + @with_feature("organizations:workflow-engine-evaluate-seer-activities") + def test_seer_activity_trigger_shown_with_feature(self) -> None: + @self.registry.register(Condition.SEER_ACTIVITY_TRIGGER) + @dataclass(frozen=True) + class TestSeerActivityTrigger(DataConditionHandler[dict[str, str]]): + group = DataConditionHandler.Group.WORKFLOW_TRIGGER + comparison_json_schema = {"type": "array", "items": {"type": "string"}} + + response = self.get_success_response( + self.organization.slug, + group=DataConditionHandler.Group.WORKFLOW_TRIGGER, + status_code=200, + ) + condition_types = [item["type"] for item in response.data] + assert Condition.SEER_ACTIVITY_TRIGGER.value in condition_types diff --git a/tests/sentry/workflow_engine/handlers/condition/test_seer_activity_trigger_handler.py b/tests/sentry/workflow_engine/handlers/condition/test_seer_activity_trigger_handler.py index 0b334c0c4b9822..e7c3a5645bc0dd 100644 --- a/tests/sentry/workflow_engine/handlers/condition/test_seer_activity_trigger_handler.py +++ b/tests/sentry/workflow_engine/handlers/condition/test_seer_activity_trigger_handler.py @@ -3,11 +3,9 @@ from sentry.types.activity import ActivityType from sentry.workflow_engine.handlers.condition.seer_activity_trigger_handler import ( - SeerActivityTriggerHandler, SeerActivityTriggerStage, ) from sentry.workflow_engine.models.data_condition import Condition -from sentry.workflow_engine.registry import condition_handler_registry from sentry.workflow_engine.types import WorkflowEventData from tests.sentry.workflow_engine.handlers.condition.test_base import ConditionTestCase @@ -17,22 +15,12 @@ class TestSeerActivityTriggerHandler(ConditionTestCase): def setUp(self) -> None: super().setUp() - # TODO(Leander): Remove after registering - condition_handler_registry.registrations[self.condition] = SeerActivityTriggerHandler - condition_handler_registry.reverse_lookup[SeerActivityTriggerHandler] = self.condition - self.dc = self.create_data_condition( type=self.condition, comparison=[SeerActivityTriggerStage.RCA_COMPLETED], condition_result=True, ) - def tearDown(self) -> None: - super().tearDown() - # TODO(Leander): Remove after registering - condition_handler_registry.registrations.pop(self.condition, None) - condition_handler_registry.reverse_lookup.pop(SeerActivityTriggerHandler, None) - def _create_event_data(self, activity_type: ActivityType) -> WorkflowEventData: activity = self.create_group_activity(group=self.group, type=activity_type.value) return WorkflowEventData(event=activity, group=self.group) 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, + ) 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( 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" },