diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 858deb0a38ec..23e9f2fc6468 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,6 +10,7 @@ ## tell the contributor to move the files to someplace with better ownership. /src/sentry/api/ @getsentry/app-backend /src/sentry/utils/ @getsentry/app-backend +/tests/sentry/utils/ @getsentry/app-backend /src/sentry/testutils/ @getsentry/app-backend /src/sentry/users/ @getsentry/app-backend /tests/sentry/api/ @getsentry/app-backend @@ -494,9 +495,10 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /src/sentry/runner/commands/createproject.py @getsentry/ecosystem /src/sentry/runner/commands/createorg.py @getsentry/ecosystem +/src/sentry/runner/commands/deletions.py @getsentry/ecosystem /tests/sentry/runner/commands/test_createproject.py @getsentry/ecosystem /tests/sentry/runner/commands/test_createorg.py @getsentry/ecosystem - +/tests/sentry/runner/commands/test_deletions.py @getsentry/ecosystem ## End of Integrations @@ -712,6 +714,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /tests/sentry/tasks/test_auto_remove_inbox.py @getsentry/issue-detection-backend /tests/sentry/tasks/test_auto_resolve_issues.py @getsentry/issue-detection-backend /tests/sentry/tasks/seer/test_delete_seer_grouping_records.py @getsentry/issue-detection-backend +/tests/sentry/tasks/test_console_platform_cleanup.py @getsentry/gdx /tests/sentry/tasks/test_check_new_issue_threshold_met.py @getsentry/issue-detection-backend /tests/sentry/tasks/test_clear_expired_resolutions.py @getsentry/issue-detection-backend /tests/sentry/tasks/test_clear_expired_rulesnoozes.py @getsentry/issue-detection-backend @@ -744,6 +747,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /src/sentry/api/endpoints/check_am2_compatibility.py @getsentry/revenue /tests/js/getsentry-test/ @getsentry/revenue /src/sentry/billing/ @getsentry/revenue +/tests/sentry/billing/ @getsentry/revenue /src/sentry/audit_log/ @getsentry/revenue ## gsApp @@ -763,6 +767,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get ## gsAdmin # /static/gsAdmin/* unowned +/static/gsAdmin/components/changeDashboardsParallelLimitModal.tsx @getsentry/data-browsing /static/gsAdmin/components/forkCustomer.tsx @getsentry/hybrid-cloud /static/gsAdmin/components/relocation* @getsentry/hybrid-cloud /static/gsAdmin/views/relocation* @getsentry/hybrid-cloud diff --git a/.github/workflows/frontend-optional.yml b/.github/workflows/frontend-optional.yml index e018750130c2..0ec51df7b67d 100644 --- a/.github/workflows/frontend-optional.yml +++ b/.github/workflows/frontend-optional.yml @@ -12,7 +12,7 @@ concurrency: # hack for https://github.com/actions/cache/issues/810#issuecomment-1222550359 env: SEGMENT_DOWNLOAD_TIMEOUT_MINS: 3 - NODE_OPTIONS: '--max-old-space-size=4096' + NODE_OPTIONS: '--max-old-space-size=8192' jobs: files-changed: diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index c0cf8b6737f8..5ed8b2bb60e2 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -15,7 +15,7 @@ concurrency: # hack for https://github.com/actions/cache/issues/810#issuecomment-1222550359 env: SEGMENT_DOWNLOAD_TIMEOUT_MINS: 3 - NODE_OPTIONS: '--max-old-space-size=4096' + NODE_OPTIONS: '--max-old-space-size=8192' jobs: files-changed: diff --git a/eslint.config.ts b/eslint.config.ts index 5dfce125646e..f2f08d52d3ae 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -49,9 +49,9 @@ import globals from 'globals'; import invariant from 'invariant'; import typescript from 'typescript-eslint'; -// eslint-disable-next-line boundaries/element-types +// eslint-disable-next-line boundaries/dependencies import * as sentryScrapsPlugin from './static/eslint/eslintPluginScraps/index'; -// eslint-disable-next-line boundaries/element-types +// eslint-disable-next-line boundaries/dependencies import * as sentryPlugin from './static/eslint/eslintPluginSentry/index'; invariant(react.configs.flat, 'For typescript'); @@ -1246,11 +1246,14 @@ export default typescript.config([ 'boundaries/no-ignored': 'off', 'boundaries/no-private': 'off', 'boundaries/no-unknown': 'off', - 'boundaries/element-types': [ + // Deprecated in v6 in favor of boundaries/dependencies. The strict preset + // still enables it, so we turn it off to avoid running both rules. + 'boundaries/element-types': 'off', + 'boundaries/dependencies': [ 'error', { default: 'disallow', - message: '${file.type} is not allowed to import ${dependency.type}', + message: '{{from.type}} is not allowed to import {{to.type}}', rules: [ // --- figma code connect --- { @@ -1353,7 +1356,7 @@ export default typescript.config([ name: 'files/core-inspector', files: ['static/app/components/core/inspector.tsx'], rules: { - 'boundaries/element-types': 'off', + 'boundaries/dependencies': 'off', }, }, ]); diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index f1d2336b2949..5dc8112c1848 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -31,7 +31,7 @@ replays: 0007_organizationmember_replay_access seer: 0005_delete_seerorganizationsettings -sentry: 1058_change_code_mapping_unique_constraint +sentry: 1062_backfill_eventattachment_date_expires social_auth: 0003_social_auth_json_field diff --git a/package.json b/package.json index 5067ac389d35..4c341ab8ef6e 100644 --- a/package.json +++ b/package.json @@ -256,7 +256,7 @@ "eslint": "9.34.0", "eslint-config-prettier": "10.1.8", "eslint-import-resolver-typescript": "^3.8.3", - "eslint-plugin-boundaries": "5.3.1", + "eslint-plugin-boundaries": "6.0.2", "eslint-plugin-import": "2.32.0", "eslint-plugin-jest": "29.15.0", "eslint-plugin-jest-dom": "^5.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2383794ae15..02428225b1b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -626,8 +626,8 @@ importers: specifier: ^3.8.3 version: 3.8.3(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-boundaries: - specifier: 5.3.1 - version: 5.3.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + specifier: 6.0.2 + version: 6.0.2(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-import: specifier: 2.32.0 version: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) @@ -1525,8 +1525,8 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} - '@boundaries/elements@1.1.2': - resolution: {integrity: sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==} + '@boundaries/elements@2.0.1': + resolution: {integrity: sha512-sAWO3D8PFP6pBXdxxW93SQi/KQqqhE2AAHo3AgWfdtJXwO6bfK6/wUN81XnOZk0qRC6vHzUEKhjwVD9dtDWvxg==} engines: {node: '>=18.18'} '@cspotcode/source-map-support@0.8.1': @@ -5526,8 +5526,8 @@ packages: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.20.0: - resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} entities@2.0.0: @@ -5706,8 +5706,8 @@ packages: eslint-import-resolver-webpack: optional: true - eslint-plugin-boundaries@5.3.1: - resolution: {integrity: sha512-91StsOYtDyrna1fyRJ+1Ps5CnrfyFLbdCouPZ3E/o2cllLxJke3OoScdqjpBSl7pNEYbojhpNlurQAr30sf9Bg==} + eslint-plugin-boundaries@6.0.2: + resolution: {integrity: sha512-wSHgiYeMEbziP91lH0UQ9oslgF2djG1x+LV9z/qO19ggMKZaCB8pKIGePHAY91eLF4EAgpsxQk8MRSFGRPfPzw==} engines: {node: '>=18.18'} peerDependencies: eslint: '>=6.0.0' @@ -6295,8 +6295,8 @@ packages: handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -8977,6 +8977,10 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + terser-webpack-plugin@5.4.0: resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==} engines: {node: '>= 10.13.0'} @@ -10752,7 +10756,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.27.2 '@babel/types': 7.29.0 - debug: 4.4.1 + debug: 4.4.3 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -10765,7 +10769,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.27.2 '@babel/types': 7.29.0 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -10798,11 +10802,11 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} - '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1))': + '@boundaries/elements@2.0.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1))': dependencies: eslint-import-resolver-node: 0.3.9 eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) - handlebars: 4.7.8 + handlebars: 4.7.9 is-core-module: 2.16.1 micromatch: 4.0.8 transitivePeerDependencies: @@ -14046,7 +14050,7 @@ snapshots: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.1 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 semver: 7.7.3 @@ -15396,10 +15400,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.1 - enhanced-resolve@5.20.0: + enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.2 entities@2.0.0: {} @@ -15660,7 +15664,7 @@ snapshots: dependencies: debug: 3.2.7 is-core-module: 2.16.1 - resolve: 1.22.10 + resolve: 1.22.11 transitivePeerDependencies: - supports-color @@ -15710,13 +15714,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): + eslint-plugin-boundaries@6.0.2(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)): dependencies: - '@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + '@boundaries/elements': 2.0.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) chalk: 4.1.2 eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@9.34.0(jiti@2.6.1)) + handlebars: 4.7.9 micromatch: 4.0.8 transitivePeerDependencies: - '@typescript-eslint/parser' @@ -16458,7 +16463,7 @@ snapshots: handle-thing@2.0.1: {} - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -16704,7 +16709,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -16738,7 +16743,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -18226,7 +18231,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.3 decode-named-character-reference: 1.1.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -19993,6 +19998,8 @@ snapshots: tapable@2.3.0: {} + tapable@2.3.2: {} + terser-webpack-plugin@5.4.0(esbuild@0.25.10)(webpack@5.99.6(esbuild@0.25.10)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -20266,7 +20273,7 @@ snapshots: '@types/node': 22.19.2 '@types/unist': 3.0.3 concat-stream: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 extend: 3.0.2 glob: 10.4.5 ignore: 6.0.2 @@ -20637,7 +20644,7 @@ snapshots: acorn: 8.16.0 browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.20.0 + enhanced-resolve: 5.20.1 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -20648,7 +20655,7 @@ snapshots: mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 4.3.3 - tapable: 2.3.0 + tapable: 2.3.2 terser-webpack-plugin: 5.4.0(esbuild@0.25.10)(webpack@5.99.6(esbuild@0.25.10)) watchpack: 2.5.1 webpack-sources: 3.3.4 diff --git a/src/sentry/api/endpoints/api_application_rotate_secret.py b/src/sentry/api/endpoints/api_application_rotate_secret.py index 5dd2f189e7a6..79d392bafc8a 100644 --- a/src/sentry/api/endpoints/api_application_rotate_secret.py +++ b/src/sentry/api/endpoints/api_application_rotate_secret.py @@ -16,7 +16,7 @@ class ApiApplicationRotateSecretEndpoint(ApiApplicationEndpoint): publish_status = { "POST": ApiPublishStatus.PRIVATE, } - owner = ApiOwner.ENTERPRISE + owner = ApiOwner.ECOSYSTEM authentication_classes = (SessionAuthentication,) permission_classes = (SentryIsAuthenticated,) diff --git a/src/sentry/api/endpoints/api_authorizations.py b/src/sentry/api/endpoints/api_authorizations.py index 9711451d69d2..cc9b75f3562a 100644 --- a/src/sentry/api/endpoints/api_authorizations.py +++ b/src/sentry/api/endpoints/api_authorizations.py @@ -22,7 +22,7 @@ class ApiAuthorizationsEndpoint(Endpoint): "DELETE": ApiPublishStatus.PRIVATE, "GET": ApiPublishStatus.PRIVATE, } - owner = ApiOwner.ENTERPRISE + owner = ApiOwner.ECOSYSTEM authentication_classes = (SessionAuthentication,) permission_classes = (SentryIsAuthenticated,) diff --git a/src/sentry/api/endpoints/builtin_symbol_sources.py b/src/sentry/api/endpoints/builtin_symbol_sources.py index aa1182425ce9..db9982b4291b 100644 --- a/src/sentry/api/endpoints/builtin_symbol_sources.py +++ b/src/sentry/api/endpoints/builtin_symbol_sources.py @@ -6,7 +6,8 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import Endpoint, cell_silo_endpoint +from sentry.api.base import cell_silo_endpoint +from sentry.api.bases.organization import OrganizationEndpoint from sentry.api.serializers import serialize from sentry.models.organization import Organization from sentry.utils.console_platforms import organization_has_console_platform_access @@ -22,41 +23,34 @@ def normalize_symbol_source(key, source): @cell_silo_endpoint -class BuiltinSymbolSourcesEndpoint(Endpoint): +class BuiltinSymbolSourcesEndpoint(OrganizationEndpoint): owner = ApiOwner.OWNERS_INGEST publish_status = { "GET": ApiPublishStatus.PRIVATE, } - permission_classes = () - def get(self, request: Request, **kwargs) -> Response: + def get(self, request: Request, organization: Organization, **kwargs) -> Response: platform = request.GET.get("platform") - # Get organization if organization context is available - organization = None - organization_id_or_slug = kwargs.get("organization_id_or_slug") - if organization_id_or_slug: - try: - if str(organization_id_or_slug).isdecimal(): - organization = Organization.objects.get_from_cache(id=organization_id_or_slug) - else: - organization = Organization.objects.get_from_cache(slug=organization_id_or_slug) - except Organization.DoesNotExist: - pass - sources = [] for key, source in settings.SENTRY_BUILTIN_SOURCES.items(): source_platforms: list[str] | None = cast("list[str] | None", source.get("platforms")) - # If source has platform restrictions, check if current platform matches + # If source has platform restrictions, only show it if: + # 1. A platform is specified (required to view platform-restricted sources), AND + # 2. The organization has access to at least one of the source's + # required console platforms. + # + # Any project type can see console sources if the org has the necessary + # access. Auto-enable still only applies to recognized project platforms. if source_platforms is not None: - if not platform or platform not in source_platforms: + if not platform: continue - - # Platform matches - now check if organization has access to this console platform - if not organization or not organization_has_console_platform_access( - organization, platform - ): + has_access = any( + organization_has_console_platform_access(organization, p) + for p in source_platforms + ) + if not has_access: continue sources.append(normalize_symbol_source(key, source)) diff --git a/src/sentry/api/endpoints/organization_auth_token_details.py b/src/sentry/api/endpoints/organization_auth_token_details.py index dad86cbb76e4..69d8ad37ae69 100644 --- a/src/sentry/api/endpoints/organization_auth_token_details.py +++ b/src/sentry/api/endpoints/organization_auth_token_details.py @@ -23,7 +23,7 @@ class OrganizationAuthTokenDetailsEndpoint(ControlSiloOrganizationEndpoint): "GET": ApiPublishStatus.PRIVATE, "PUT": ApiPublishStatus.PRIVATE, } - owner = ApiOwner.ENTERPRISE + owner = ApiOwner.ECOSYSTEM authentication_classes = (SessionNoAuthTokenAuthentication,) permission_classes = (OrgAuthTokenPermission, DisallowImpersonatedTokenCreation) diff --git a/src/sentry/api/endpoints/organization_auth_tokens.py b/src/sentry/api/endpoints/organization_auth_tokens.py index a44c2dca7f1b..69df1892110d 100644 --- a/src/sentry/api/endpoints/organization_auth_tokens.py +++ b/src/sentry/api/endpoints/organization_auth_tokens.py @@ -42,7 +42,7 @@ class OrganizationAuthTokensEndpoint(ControlSiloOrganizationEndpoint): "GET": ApiPublishStatus.PRIVATE, "POST": ApiPublishStatus.PRIVATE, } - owner = ApiOwner.ENTERPRISE + owner = ApiOwner.ECOSYSTEM authentication_classes = (SessionNoAuthTokenAuthentication,) permission_classes = (OrgAuthTokenPermission, DisallowImpersonatedTokenCreation) diff --git a/src/sentry/api/endpoints/timeseries.py b/src/sentry/api/endpoints/timeseries.py index 190ffd93ea83..20aeae732171 100644 --- a/src/sentry/api/endpoints/timeseries.py +++ b/src/sentry/api/endpoints/timeseries.py @@ -33,7 +33,7 @@ class SeriesMeta(TypedDict): class GroupBy(TypedDict): key: str - value: str | None + value: str | float | None class TimeSeries(TypedDict): diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 187c24d432cd..30225f90c216 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -270,6 +270,9 @@ from sentry.integrations.api.endpoints.organization_integration_details import ( OrganizationIntegrationDetailsEndpoint, ) +from sentry.integrations.api.endpoints.organization_integration_direct_enable import ( + OrganizationIntegrationDirectEnableEndpoint, +) from sentry.integrations.api.endpoints.organization_integration_issues import ( OrganizationIntegrationIssuesEndpoint, ) @@ -1970,6 +1973,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: OrganizationIntegrationsEndpoint.as_view(), name="sentry-api-0-organization-integrations", ), + re_path( + r"^(?P[^/]+)/integrations/direct-enable/(?P[^/]+)/$", + OrganizationIntegrationDirectEnableEndpoint.as_view(), + name="sentry-api-0-organization-integration-direct-enable", + ), re_path( r"^(?P[^/]+)/integrations/coding-agents/$", OrganizationCodingAgentsEndpoint.as_view(), diff --git a/src/sentry/attachments/base.py b/src/sentry/attachments/base.py index a2faccbcb2f9..7a38a2cd60bf 100644 --- a/src/sentry/attachments/base.py +++ b/src/sentry/attachments/base.py @@ -1,11 +1,13 @@ from __future__ import annotations from collections.abc import Generator +from datetime import timedelta from typing import TYPE_CHECKING import zstandard +from objectstore_client import TimeToLive -from sentry.objectstore import get_attachments_session +from sentry.objectstore import default_attachment_retention, get_attachments_session from sentry.utils import metrics from sentry.utils.json import prune_empty_keys @@ -36,6 +38,7 @@ def __init__( cache=None, rate_limited=None, size=None, + retention_days=None, **kwargs, ): self.key = key @@ -46,6 +49,7 @@ def __init__( self.type = type or "event.attachment" assert isinstance(self.type, str), self.type self.rate_limited = rate_limited + self.retention_days = retention_days or default_attachment_retention() if size is not None: self.size = size @@ -111,6 +115,7 @@ def meta(self) -> dict: "size": self.size or None, # None for backwards compatibility "chunks": self.chunks, "stored_id": self.stored_id, + "retention_days": self.retention_days, } ) @@ -138,7 +143,11 @@ def set( if attachment.stored_id is not None and attachment._data is not UNINITIALIZED_DATA: assert project session = get_attachments_session(project.organization_id, project.id) - session.put(attachment._data, key=attachment.stored_id) + session.put( + contents=attachment._data, + key=attachment.stored_id, + expiration_policy=TimeToLive(timedelta(days=attachment.retention_days)), + ) # the attachment is stored either in objectstore or in the attachment cache already if attachment.chunks is not None or attachment.stored_id is not None: diff --git a/src/sentry/billing/platform/services/usage/_category_mapping.py b/src/sentry/billing/platform/services/usage/_category_mapping.py new file mode 100644 index 000000000000..8bbe76019934 --- /dev/null +++ b/src/sentry/billing/platform/services/usage/_category_mapping.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging + +from sentry_protos.billing.v1.data_category_pb2 import DataCategory as ProtoDataCategory + +from sentry.constants import DataCategory +from sentry.utils import metrics + +logger = logging.getLogger(__name__) + +# Proto DataCategory enum uses different int values from Relay/Sentry DataCategory. +# e.g., Proto ATTACHMENT=3 vs Relay ATTACHMENT=4. +# ClickHouse stores Relay ints. The proto request carries proto ints. +# This mapping converts between the two. +PROTO_TO_RELAY_CATEGORY: dict[int, int] = { + ProtoDataCategory.DATA_CATEGORY_ERROR: int(DataCategory.ERROR), + ProtoDataCategory.DATA_CATEGORY_TRANSACTION: int(DataCategory.TRANSACTION), + ProtoDataCategory.DATA_CATEGORY_ATTACHMENT: int(DataCategory.ATTACHMENT), + ProtoDataCategory.DATA_CATEGORY_PROFILE: int(DataCategory.PROFILE), + ProtoDataCategory.DATA_CATEGORY_REPLAY: int(DataCategory.REPLAY), + ProtoDataCategory.DATA_CATEGORY_MONITOR: int(DataCategory.MONITOR), + ProtoDataCategory.DATA_CATEGORY_SPAN: int(DataCategory.SPAN), + ProtoDataCategory.DATA_CATEGORY_USER_REPORT_V2: int(DataCategory.USER_REPORT_V2), + ProtoDataCategory.DATA_CATEGORY_PROFILE_DURATION: int(DataCategory.PROFILE_DURATION), + ProtoDataCategory.DATA_CATEGORY_LOG_BYTE: int(DataCategory.LOG_BYTE), + ProtoDataCategory.DATA_CATEGORY_PROFILE_DURATION_UI: int(DataCategory.PROFILE_DURATION_UI), + ProtoDataCategory.DATA_CATEGORY_SEER_AUTOFIX: int(DataCategory.SEER_AUTOFIX), + ProtoDataCategory.DATA_CATEGORY_SEER_SCANNER: int(DataCategory.SEER_SCANNER), + ProtoDataCategory.DATA_CATEGORY_SIZE_ANALYSIS: int(DataCategory.SIZE_ANALYSIS), + ProtoDataCategory.DATA_CATEGORY_INSTALLABLE_BUILD: int(DataCategory.INSTALLABLE_BUILD), + ProtoDataCategory.DATA_CATEGORY_TRACE_METRIC: int(DataCategory.TRACE_METRIC), + ProtoDataCategory.DATA_CATEGORY_DEFAULT: int(DataCategory.DEFAULT), + ProtoDataCategory.DATA_CATEGORY_SECURITY: int(DataCategory.SECURITY), + ProtoDataCategory.DATA_CATEGORY_PROFILE_CHUNK: int(DataCategory.PROFILE_CHUNK), + ProtoDataCategory.DATA_CATEGORY_PROFILE_CHUNK_UI: int(DataCategory.PROFILE_CHUNK_UI), +} + + +def proto_to_relay_category(proto_category: int) -> int: + """Convert a proto DataCategory int to the Relay/Sentry int used in ClickHouse.""" + result = PROTO_TO_RELAY_CATEGORY.get(proto_category) + if result is None: + metrics.incr( + "billing.proto_category_mapping.unmapped", + tags={"proto_category": str(proto_category)}, + sample_rate=1.0, + ) + return proto_category + return result diff --git a/src/sentry/billing/platform/services/usage/_outcomes_query.py b/src/sentry/billing/platform/services/usage/_outcomes_query.py new file mode 100644 index 000000000000..5e16ef64bab1 --- /dev/null +++ b/src/sentry/billing/platform/services/usage/_outcomes_query.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from collections.abc import Sequence +from datetime import datetime, timedelta, timezone + +from google.protobuf.timestamp_pb2 import Timestamp +from sentry_protos.billing.v1.date_pb2 import Date +from sentry_protos.billing.v1.services.usage.v1.endpoint_usage_pb2 import ( + CategoryUsage, + DailyUsage, + GetUsageRequest, + GetUsageResponse, +) +from sentry_protos.billing.v1.usage_data_pb2 import UsageData +from snuba_sdk import ( + Column, + Condition, + Entity, + Function, + Granularity, + Limit, + Op, + OrderBy, + Query, + Request, +) +from snuba_sdk.orderby import Direction + +from sentry.billing.platform.services.usage._category_mapping import proto_to_relay_category +from sentry.snuba.referrer import Referrer +from sentry.utils import metrics +from sentry.utils.outcomes import Outcome +from sentry.utils.snuba import raw_snql_query + +logger = logging.getLogger(__name__) + +_REFERRER = Referrer.BILLING_USAGE_SERVICE_CLICKHOUSE.value +_APP_ID = "billing" +_DATASET = "outcomes" +_DAILY_GRANULARITY = 86400 +_QUERY_LIMIT = 10000 + +# Outcomes stored in PG BillingMetricUsage (getsentry outcomes consumer +# filters to these three at ingest). The CH outcomes table also has +# INVALID, ABUSE, CLIENT_DISCARD, and CARDINALITY_LIMITED. +_BILLABLE_OUTCOMES = [Outcome.ACCEPTED, Outcome.FILTERED, Outcome.RATE_LIMITED] + + +def query_outcomes_usage(request: GetUsageRequest) -> GetUsageResponse: + org_id = request.organization_id + start = _timestamp_to_datetime(request.start) + # The proto contract defines `end` as inclusive (midnight of the last + # included day). Snuba queries use a half-open interval [start, end), + # so we add one day to convert inclusive→exclusive. Without this, all + # hourly rows on the last day would be excluded. + end = _timestamp_to_datetime(request.end) + timedelta(days=1) + # Proto categories use different int values from Relay/ClickHouse + # (e.g., proto ATTACHMENT=3 vs Relay ATTACHMENT=4). Convert before querying. + categories = [proto_to_relay_category(c) for c in request.categories] + + snuba_request = _build_query(org_id, start, end, categories, total_outcomes=_BILLABLE_OUTCOMES) + result = raw_snql_query(snuba_request, referrer=_REFERRER) + rows = result["data"] + + if len(rows) >= _QUERY_LIMIT: + logger.warning( + "billing.usage_query.truncated", + extra={"org_id": org_id, "row_count": len(rows)}, + ) + metrics.incr( + "billing.usage_query.truncated", + tags={"org_id": str(org_id)}, + sample_rate=1.0, + ) + + return _build_response(rows) + + +def _build_query( + org_id: int, + start: datetime, + end: datetime, + categories: Sequence[int], + *, + total_outcomes: Sequence[int] | None = None, +) -> Request: + # Half-open interval [start, end) — standard sentry.snuba.outcomes convention. + # `end` has already been shifted +1 day in query_outcomes_usage() to convert + # the proto's inclusive end into the exclusive boundary Snuba expects. + where = [ + Condition(Column("org_id"), Op.EQ, org_id), + Condition(Column("timestamp"), Op.GTE, start), + Condition(Column("timestamp"), Op.LT, end), + ] + if categories: + where.append(Condition(Column("category"), Op.IN, categories)) + + query = Query( + match=Entity("outcomes"), + select=[ + Column("category"), + Column("time"), + _total_function(total_outcomes), + Function( + "sumIf", + [Column("quantity"), Function("equals", [Column("outcome"), Outcome.ACCEPTED])], + "accepted", + ), + Function( + "sumIf", + [ + Column("quantity"), + Function("equals", [Column("outcome"), Outcome.RATE_LIMITED]), + ], + "dropped", + ), + Function( + "sumIf", + [Column("quantity"), Function("equals", [Column("outcome"), Outcome.FILTERED])], + "filtered", + ), + Function("sumIf", [Column("quantity"), _over_quota_condition()], "over_quota"), + Function( + "sumIf", + [ + Column("quantity"), + Function( + "and", + [ + Function("equals", [Column("outcome"), Outcome.RATE_LIMITED]), + Function("equals", [Column("reason"), "smart_rate_limit"]), + ], + ), + ], + "spike_protection", + ), + Function( + "sumIf", + [ + Column("quantity"), + Function( + "and", + [ + Function("equals", [Column("outcome"), Outcome.FILTERED]), + Function("startsWith", [Column("reason"), "Sampled:"]), + ], + ), + ], + "dynamic_sampling", + ), + ], + groupby=[Column("category"), Column("time")], + where=where, + orderby=[OrderBy(Column("time"), Direction.ASC)], + granularity=Granularity(_DAILY_GRANULARITY), + limit=Limit(_QUERY_LIMIT), + ) + return Request( + dataset=_DATASET, + app_id=_APP_ID, + query=query, + tenant_ids={"organization_id": org_id}, + ) + + +def _build_response(rows: list[dict]) -> GetUsageResponse: + # Two-level accumulator: days_map[day_str][category_id] -> usage fields. + # Each row already contains all 7 sumIf-aggregated fields from ClickHouse. + # + # NOTE: CategoryUsage.category carries Relay/Sentry int values (not proto + # DataCategory ints). The proto field is typed as DataCategory but every + # existing consumer (getsentry postgres backend, shadow comparison, + # UsagePricerService, customer_usage, projection, etc.) interprets it as a + # Relay int. Converting to proto ints here would break all consumers and + # the shadow comparison. See the TODO in getsentry's + # usage_pricer/service.py for the planned migration. + days_map: defaultdict[str, dict[int, dict[str, int]]] = defaultdict(dict) + + for row in rows: + day = row["time"] + category = int(row["category"]) + days_map[day][category] = { + "total": int(row["total"]), + "accepted": int(row["accepted"]), + "dropped": int(row["dropped"]), + "filtered": int(row["filtered"]), + "over_quota": int(row["over_quota"]), + "spike_protection": int(row["spike_protection"]), + "dynamic_sampling": int(row["dynamic_sampling"]), + } + + days = [] + for day_str in sorted(days_map): + date = _parse_day(day_str) + usage = [ + CategoryUsage(category=cat, data=UsageData(**fields)) # type: ignore[arg-type] + for cat, fields in sorted(days_map[day_str].items()) + ] + days.append(DailyUsage(date=date, usage=usage)) + + return GetUsageResponse(days=days, seats=[]) + + +def _total_function(outcomes: Sequence[int] | None) -> Function: + """Build the ``total`` aggregate. + + When *outcomes* is provided, only those outcome types are counted + (billing callers pass ``_BILLABLE_OUTCOMES``). When ``None``, every + outcome is counted (useful for general-purpose usage queries). + """ + if outcomes is None: + return Function("sum", [Column("quantity")], "total") + return Function( + "sumIf", + [ + Column("quantity"), + Function( + "in", + [ + Column("outcome"), + Function("tuple", list(outcomes)), + ], + ), + ], + "total", + ) + + +def _over_quota_condition() -> Function: + """ClickHouse condition for over-quota rate limiting. + + Matches: outcome=RATE_LIMITED AND (reason ends with "_usage_exceeded" + OR reason="usage_exceeded" OR reason="grace_period"). + """ + return Function( + "and", + [ + Function("equals", [Column("outcome"), Outcome.RATE_LIMITED]), + Function( + "or", + [ + Function("endsWith", [Column("reason"), "_usage_exceeded"]), + Function( + "or", + [ + Function("equals", [Column("reason"), "usage_exceeded"]), + Function("equals", [Column("reason"), "grace_period"]), + ], + ), + ], + ), + ], + ) + + +def _timestamp_to_datetime(ts: Timestamp) -> datetime: + return ts.ToDatetime(tzinfo=timezone.utc) + + +def _parse_day(value: str) -> Date: + dt = datetime.fromisoformat(value) + return Date(year=dt.year, month=dt.month, day=dt.day) diff --git a/src/sentry/billing/platform/services/usage/service.py b/src/sentry/billing/platform/services/usage/service.py index ba4f158c2127..cf2452deda1d 100644 --- a/src/sentry/billing/platform/services/usage/service.py +++ b/src/sentry/billing/platform/services/usage/service.py @@ -6,6 +6,7 @@ ) from sentry.billing.platform.core import BillingService, service_method +from sentry.billing.platform.services.usage._outcomes_query import query_outcomes_usage class UsageService(BillingService): @@ -18,6 +19,4 @@ def get_usage(self, request: GetUsageRequest) -> GetUsageResponse: accepted, dropped, filtered, over_quota, spike_protection, and dynamic_sampling. """ - # Default implementation returns empty response. - # GetSentry overrides this with Postgres/ClickHouse backends. - return GetUsageResponse() + return query_outcomes_usage(request) diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index c83313afaa50..de5184194252 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -948,6 +948,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.tasks.collect_project_platforms", "sentry.tasks.commit_context", "sentry.tasks.commits", + "sentry.tasks.console_platform_cleanup", "sentry.tasks.delete_pending_groups", "sentry.tasks.seer.delete_seer_grouping_records", "sentry.tasks.digests", @@ -2239,6 +2240,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: "sentry.integrations.opsgenie.OpsgenieIntegrationProvider", "sentry.integrations.cursor.integration.CursorAgentIntegrationProvider", "sentry.integrations.claude_code.integration.ClaudeCodeAgentIntegrationProvider", + "sentry.integrations.github_copilot.integration.GithubCopilotIntegrationProvider", "sentry.integrations.perforce.integration.PerforceIntegrationProvider", ) diff --git a/src/sentry/core/endpoints/organization_details.py b/src/sentry/core/endpoints/organization_details.py index a3c7fd1f39b7..9f528e0c6561 100644 --- a/src/sentry/core/endpoints/organization_details.py +++ b/src/sentry/core/endpoints/organization_details.py @@ -111,6 +111,7 @@ from sentry.replays.models import OrganizationMemberReplayAccess from sentry.seer.autofix.constants import AutofixAutomationTuningSettings from sentry.services.organization.provisioning import organization_provisioning_service +from sentry.tasks.console_platform_cleanup import remove_inaccessible_console_platform_sources from sentry.users.services.user.serial import serialize_generic_user from sentry.utils.audit import create_audit_entry @@ -1304,12 +1305,25 @@ def put(self, request: Request, organization: Organization) -> Response: CellScheduledDeletion.cancel(organization) elif changed_data: if "enabledConsolePlatforms" in changed_data: + current_console_platforms = serializer.validated_data.get( + "enabledConsolePlatforms", [] + ) create_console_platform_audit_log( request, organization, previous_console_platforms, - serializer.validated_data.get("enabledConsolePlatforms", []), + current_console_platforms, + ) + + # If any console platforms were revoked, clean up their + # symbol sources from all projects in the org. + revoked_platforms = set(previous_console_platforms or []) - set( + current_console_platforms ) + if revoked_platforms: + remove_inaccessible_console_platform_sources.delay( + organization.id, current_console_platforms + ) del changed_data["enabledConsolePlatforms"] diff --git a/src/sentry/dashboards/endpoints/organization_dashboard_generate.py b/src/sentry/dashboards/endpoints/organization_dashboard_generate.py index fcd96d724318..66b90b0d6f37 100644 --- a/src/sentry/dashboards/endpoints/organization_dashboard_generate.py +++ b/src/sentry/dashboards/endpoints/organization_dashboard_generate.py @@ -13,6 +13,7 @@ from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission +from sentry.api.serializers.rest_framework import DashboardDetailsSerializer from sentry.dashboards.models.generate_dashboard_artifact import GeneratedDashboard from sentry.dashboards.on_completion_hook import DashboardOnCompletionHook from sentry.models.organization import Organization @@ -21,15 +22,29 @@ from sentry.seer.models import SeerApiError, SeerPermissionError from sentry.seer.seer_setup import has_seer_access_with_detail from sentry.types.ratelimit import RateLimit, RateLimitCategory +from sentry.utils import json logger = logging.getLogger(__name__) +CREATE_ON_PAGE_CONTEXT = "The user is on the dashboard generation page. This session must ONLY generate a dashboard artifact. Do not perform code changes or any tasks unrelated to dashboard generation." + +EDIT_ON_PAGE_CONTEXT_TEMPLATE = """The user is editing an existing dashboard. The current dashboard state is: + +{current_dashboard_json} + +This session must ONLY modify the dashboard artifact. Produce a COMPLETE dashboard artifact that incorporates the requested changes while preserving widgets the user did not ask to change. Do not perform code changes or any tasks unrelated to dashboard editing.""" + class DashboardGenerateSerializer(serializers.Serializer[dict[str, Any]]): prompt = serializers.CharField( required=True, allow_blank=False, - help_text="Natural language description of the dashboard to generate.", + help_text="Natural language description of the dashboard to generate or edit.", + ) + current_dashboard = serializers.JSONField( + required=False, + default=None, + help_text="JSON representation of the current dashboard state to edit.", ) @@ -71,7 +86,28 @@ def post(self, request: Request, organization: Organization) -> Response: if not serializer.is_valid(): return Response(serializer.errors, status=400) - prompt = serializer.validated_data["prompt"] + validated_data = serializer.validated_data + prompt = validated_data["prompt"] + current_dashboard = validated_data.get("current_dashboard") + + # If current_dashboard is provided, we're editing; otherwise generating a new dashboard. + if current_dashboard is not None: + dashboard_serializer = DashboardDetailsSerializer( + data=current_dashboard, + context={ + "organization": organization, + "request": request, + "projects": self.get_projects(request, organization), + }, + ) + if not dashboard_serializer.is_valid(): + return Response(dashboard_serializer.errors, status=400) + + on_page_context = EDIT_ON_PAGE_CONTEXT_TEMPLATE.format( + current_dashboard_json=json.dumps(current_dashboard) + ) + else: + on_page_context = CREATE_ON_PAGE_CONTEXT try: client = SeerExplorerClient( @@ -83,7 +119,7 @@ def post(self, request: Request, organization: Organization) -> Response: ) run_id = client.start_run( prompt=prompt, - on_page_context="The user is on the dashboard generation page. This session must ONLY generate a dashboard artifact. Do not perform code changes or any tasks unrelated to dashboard generation.", + on_page_context=on_page_context, artifact_key="dashboard", artifact_schema=GeneratedDashboard, request=request, diff --git a/src/sentry/data_secrecy/api/waive_data_secrecy.py b/src/sentry/data_secrecy/api/waive_data_secrecy.py index c33ce0fcadf5..b56c296d407a 100644 --- a/src/sentry/data_secrecy/api/waive_data_secrecy.py +++ b/src/sentry/data_secrecy/api/waive_data_secrecy.py @@ -53,7 +53,7 @@ class WaiveDataSecrecyEndpoint(ControlSiloOrganizationEndpoint): "POST": ApiPublishStatus.PRIVATE, "DELETE": ApiPublishStatus.PRIVATE, } - owner = ApiOwner.ENTERPRISE + owner = ApiOwner.ECOSYSTEM def get( self, diff --git a/src/sentry/event_manager.py b/src/sentry/event_manager.py index 5f7a3bbf52a8..e5800f7556f5 100644 --- a/src/sentry/event_manager.py +++ b/src/sentry/event_manager.py @@ -2508,6 +2508,7 @@ def save_attachment( sha1=file.sha1, # storage: blob_path=file.blob_path, + date_expires=datetime.now(timezone.utc) + timedelta(days=attachment.retention_days), ) track_outcome( diff --git a/src/sentry/grouping/parameterization.py b/src/sentry/grouping/parameterization.py index dca27064f4e0..c762da26c9e1 100644 --- a/src/sentry/grouping/parameterization.py +++ b/src/sentry/grouping/parameterization.py @@ -1,10 +1,16 @@ import dataclasses import re -from collections import defaultdict +from collections import OrderedDict, defaultdict from collections.abc import Sequence +from typing import Callable from sentry.utils import metrics +# Function parameterization regexes can specify to provide a customized replacement string. Can also +# be used to do conditional replacement, by returning the original value in cases where replacement +# shouldn't happen. +ParameterizationReplacementFunction = Callable[[str], str] + @dataclasses.dataclass class ParameterizationRegex: @@ -13,6 +19,8 @@ class ParameterizationRegex: raw_pattern_experimental: str | None = None lookbehind: str | None = None # positive lookbehind prefix if needed lookahead: str | None = None # positive lookahead postfix if needed + # Function which takes the matched value and returns the replacement value. + replacement_callback: ParameterizationReplacementFunction | None = None # These need to be used with `(?x)`, to tell the regex compiler to ignore comments # and unescaped whitespace, so we can use newlines and indentation for better legibility. @@ -207,23 +215,40 @@ def _get_pattern(self, raw_pattern: str) -> str: # the prefix pretty much guarantees it's hex). (\b0[xX][0-9a-fA-F]+\b) | - # Hex value without `0x/0X` prefix (between 8 and 128 digits, including a number, and - # either all uppercase or all lowercase - we're more conservative here on all three - # scores in order to reduce false positives). - # - # Note: We use a lookahead for `0-9` but don't need one for `a-f/A-F` since if - # a) the value consists of nothing but potential hex digits, but - # b) none of those potential hex digits is a letter - # then the pattern would already have caught it. Given that we're here, it didn't, - # so the only thing we need the lookahead to guard against is it being all letters. - # - # Each regex consists of two parts, the lookahead and the hex characters themselves. For - # example, for the lowercase pattern we have: - # (?=[a-f]*[0-9]) The lookahead - there must be a digit, which may or may not be - # preceded by some number of hex letters - # [0-9a-f]{8,128} The matcher itself - between 8 and 128 hex characters - (\b(?=[a-f]*[0-9])[0-9a-f]{8,128}\b) | - (\b(?=[A-F]*[0-9])[0-9A-F]{8,128}\b) + # Hex value without `0x/0X` prefix (at least 4 characters long, containing both a letter + # and number if shorter than 8 characters, and either all uppercase or all lowercase - + # we're more conservative here in order to reduce false positives). + ( + # Regular word boundary (for positive values) + \b + | + # Alphanumeric negative lookbehind before the dash in negative values to ensure it's + # only considered a minus sign if it doesn't connect two alphanumeric strings. (No + # word boundary here because the dash serves as the word boundary, since it's not a + # word character.) + (? str: # Regular word boundary for positive ints \b | - # Alphanumeric negative lookbehind for negative ints to ensure a dash is only - # considered a minus sign if it doesn't connect two alphanumeric strings. (No word - # boundary here because the dash serves as the word boundary, since it's not a word - # character.) + # Alphanumeric negative lookbehind before the dash in negative ints (logic the same + # as in the hex pattern above) (? str: @@ -327,15 +368,23 @@ def parameterize(self, input_str: str) -> str: For example, turn "Error with order #1231" into "Error with order #". """ - matches_counter: defaultdict[str, int] = defaultdict(int) + replacement_counts: defaultdict[str, int] = defaultdict(int) + # Track whether any regex matches don't lead to a replacement + found_false_positive = False def _handle_regex_match(match: re.Match[str]) -> str: - # Since - # a) our regex consists of a bunch of named capturing groups separated by `|`, - # b) no other capturing groups in the regex are named, and - # c) there's nothing else in the regex, - # there should be exactly one named matching group, making the last matching group also - # the only matching group. + # Ensure we're dealing with the flag from the outer scope, rather than shadowing it + nonlocal found_false_positive + + # This handler gets called on two types of regexes: + # - The main/combination regex, which consists of a bunch of named capturing groups + # (with no nested named groups) separated by `|`. + # - Individual regexes for each pattern type, each consisting of one of the + # alternatives in the main regex. + # In the former case, only one alternative can have been matched, so its group name will + # be the only (and therefore last) matched group name. In the latter case, there is only + # one named group to match, so its name will automatically be the only/last matched + # group name. Thus `lastgroup` should give us the group name in either case. matched_key = match.lastgroup orig_value = match.groupdict().get( matched_key or "" # Empty string for mypy appeasment @@ -344,16 +393,44 @@ def _handle_regex_match(match: re.Match[str]) -> str: if not matched_key or not orig_value: # Insurance - shouldn't happen IRL return "" - matches_counter[matched_key] += 1 - return f"<{matched_key}>" + replacement_callback = self.replacement_functions.get(matched_key) + replacement_string = ( + replacement_callback(orig_value) if replacement_callback else f"<{matched_key}>" + ) + + # The replacement callback might return the original value, if it determines it's not + # something which should be replaced, and we don't want to count that + if replacement_string != orig_value: + replacement_counts[matched_key] += 1 + else: + found_false_positive = True + + return replacement_string with metrics.timer( "grouping.parameterize", tags={"experimental": self.is_experimental} ) as metric_tags: parameterized = self.combined_regex.sub(_handle_regex_match, input_str) + + # Our big combo regex will short-circuit when it finds a match, which is great for + # performance but problematic in cases where a replacement callback declines to + # parameterize a value, because then the value never gets checked against later + # patterns. To protect against that, in those cases we cycle through all patterns + # individually, which is slower but ensures we check them all. + if found_false_positive: + metric_tags["false_positive"] = True + + # Reset values before applying the patterns again + replacement_counts = defaultdict(int) + parameterized = input_str + + # Apply patterns one by one, with no short-circuiting + for regex_key, regex in self.compiled_regexes_by_name.items(): + parameterized = regex.sub(_handle_regex_match, parameterized) + metric_tags["changed"] = parameterized != input_str - for regex_key, count in matches_counter.items(): + for regex_key, count in replacement_counts.items(): # Track the kinds of replacements being made metrics.incr("grouping.value_parameterized", amount=count, tags={"key": regex_key}) diff --git a/src/sentry/hybridcloud/apigateway_async/middleware.py b/src/sentry/hybridcloud/apigateway_async/middleware.py index 446210ee1836..963b3530a860 100644 --- a/src/sentry/hybridcloud/apigateway_async/middleware.py +++ b/src/sentry/hybridcloud/apigateway_async/middleware.py @@ -1,10 +1,9 @@ from __future__ import annotations -import asyncio from collections.abc import Callable from typing import Any -from asgiref.sync import async_to_sync, iscoroutinefunction, markcoroutinefunction +from asgiref.sync import iscoroutinefunction, markcoroutinefunction from django.http.response import HttpResponseBase from rest_framework.request import Request @@ -15,12 +14,15 @@ class ApiGatewayMiddleware: """Proxy requests intended for remote silos""" async_capable = True - sync_capable = True + sync_capable = False def __init__(self, get_response: Callable[[Request], HttpResponseBase]): self.get_response = get_response - if iscoroutinefunction(self.get_response): - markcoroutinefunction(self) + markcoroutinefunction(self) + # NOTE: this shouldn't be necessary, but for $REASONS Django's middleware + # sync-async code patches won't recognize `process_view` as async. + # The following line does. + markcoroutinefunction(self.process_view) def __call__(self, request: Request) -> Any: if iscoroutinefunction(self): @@ -30,42 +32,7 @@ def __call__(self, request: Request) -> Any: async def __acall__(self, request: Request) -> HttpResponseBase: return await self.get_response(request) # type: ignore[misc] - def process_view( - self, - request: Request, - view_func: Callable[..., HttpResponseBase], - view_args: tuple[str], - view_kwargs: dict[str, Any], - ) -> HttpResponseBase | None: - return self._process_view_match(request, view_func, view_args, view_kwargs) - - def _process_view_match( - self, - request: Request, - view_func: Callable[..., HttpResponseBase], - view_args: tuple[str], - view_kwargs: dict[str, Any], - ) -> Any: - #: we check if we're in an async or sync runtime once, then - # overwrite the method with the actual impl. - try: - asyncio.get_running_loop() - method = self._process_view_inner - except RuntimeError: - method = self._process_view_sync # type: ignore[assignment] - setattr(self, "_process_view_match", method) - return method(request, view_func, view_args, view_kwargs) - - def _process_view_sync( - self, - request: Request, - view_func: Callable[..., HttpResponseBase], - view_args: tuple[str], - view_kwargs: dict[str, Any], - ) -> HttpResponseBase | None: - return async_to_sync(self._process_view_inner)(request, view_func, view_args, view_kwargs) - - async def _process_view_inner( + async def process_view( self, request: Request, view_func: Callable[..., HttpResponseBase], diff --git a/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py b/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py index 577b2f93994e..66bc79545609 100644 --- a/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py +++ b/src/sentry/incidents/endpoints/serializers/workflow_engine_detector.py @@ -317,6 +317,14 @@ def get_attrs( self.add_created_by(result, list(detectors.values())) self.add_owner(result, list(detectors.values())) + alert_rule_ids_by_detector_id = dict( + AlertRuleDetector.objects.filter(detector_id__in=detector_ids).values_list( + "detector_id", "alert_rule_id" + ) + ) + for detector in detectors.values(): + result[detector]["alert_rule_id"] = alert_rule_ids_by_detector_id.get(detector.id) + # Note: originalAlertRuleId comes from AlertRuleActivity snapshots, which were not # migrated to the workflow engine. This field will always be None for detectors. @@ -404,14 +412,7 @@ def serialize(self, obj: Detector, attrs, user, **kwargs) -> AlertRuleSerializer if triggers: alert_rule_id = triggers[0].get("alertRuleId") else: - try: - alert_rule_id = AlertRuleDetector.objects.values_list( - "alert_rule_id", flat=True - ).get(detector=obj) - except AlertRuleDetector.DoesNotExist: - # this detector does not have an analog in the old system, - # but we need to return *something* - alert_rule_id = get_fake_id_from_object_id(obj.id) + alert_rule_id = attrs.get("alert_rule_id") or get_fake_id_from_object_id(obj.id) comparison_delta = obj.config.get("comparison_delta") diff --git a/src/sentry/incidents/logic.py b/src/sentry/incidents/logic.py index f89bac9c4d02..72d39838788c 100644 --- a/src/sentry/incidents/logic.py +++ b/src/sentry/incidents/logic.py @@ -1141,11 +1141,6 @@ class AlertRuleTriggerLabelAlreadyUsedError(Exception): pass -class ProjectsNotAssociatedWithAlertRuleError(Exception): - def __init__(self, project_slugs: Collection[str]) -> None: - self.project_slugs = project_slugs - - def create_alert_rule_trigger( alert_rule: AlertRule, label: str, @@ -1326,28 +1321,6 @@ def deduplicate_trigger_actions( return list(deduped.values()) -def _get_subscriptions_from_alert_rule( - alert_rule: AlertRule, projects: Collection[Project] -) -> Iterable[QuerySubscription]: - """ - Fetches subscriptions associated with an alert rule filtered by a list of projects. - Raises `ProjectsNotAssociatedWithAlertRuleError` if Projects aren't associated with - the AlertRule - :param alert_rule: The AlertRule to fetch subscriptions for - :param projects: The Project we want subscriptions for - :return: A list of QuerySubscriptions - """ - excluded_subscriptions = _unpack_snuba_query(alert_rule).subscriptions.filter( - project__in=projects - ) - if len(excluded_subscriptions) != len(projects): - invalid_slugs = {p.slug for p in projects} - { - s.project.slug for s in excluded_subscriptions - } - raise ProjectsNotAssociatedWithAlertRuleError(invalid_slugs) - return excluded_subscriptions - - def create_alert_rule_trigger_action( trigger: AlertRuleTrigger, type: ActionService, diff --git a/src/sentry/integrations/api/endpoints/organization_coding_agents.py b/src/sentry/integrations/api/endpoints/organization_coding_agents.py index 96fdbc12fd5b..0fdfe5215055 100644 --- a/src/sentry/integrations/api/endpoints/organization_coding_agents.py +++ b/src/sentry/integrations/api/endpoints/organization_coding_agents.py @@ -93,7 +93,10 @@ def get(self, request: Request, organization: Organization) -> Response: if integration.provider != "github_copilot" ] - if features.has("organizations:integrations-github-copilot-agent", organization): + github_copilot_installed = any(i.provider == "github_copilot" for i in integrations) + if github_copilot_installed and features.has( + "organizations:integrations-github-copilot-agent", organization + ): has_identity = False if request.user and request.user.id: try: diff --git a/src/sentry/integrations/api/endpoints/organization_config_integrations.py b/src/sentry/integrations/api/endpoints/organization_config_integrations.py index 490f28ff1a97..7cdf2abc4ac2 100644 --- a/src/sentry/integrations/api/endpoints/organization_config_integrations.py +++ b/src/sentry/integrations/api/endpoints/organization_config_integrations.py @@ -57,9 +57,11 @@ def get(self, request: Request, organization: Organization) -> Response: def is_provider_enabled(provider): if not provider.requires_feature_flag: return True - provider_key = provider.key.replace("_", "-") - feature_flag_name = "organizations:integrations-%s" % provider_key - return features.has(feature_flag_name, organization, actor=request.user) + flag = ( + provider.feature_flag_name + or "organizations:integrations-%s" % provider.key.replace("_", "-") + ) + return features.has(flag, organization, actor=request.user) providers = list(integrations.all()) provider_key = request.GET.get("provider_key") or request.GET.get("providerKey") diff --git a/src/sentry/integrations/api/endpoints/organization_integration_direct_enable.py b/src/sentry/integrations/api/endpoints/organization_integration_direct_enable.py new file mode 100644 index 000000000000..98c4dec12155 --- /dev/null +++ b/src/sentry/integrations/api/endpoints/organization_integration_direct_enable.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Any + +from django.db import IntegrityError, router, transaction +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 control_silo_endpoint +from sentry.api.bases.organization import ( + ControlSiloOrganizationEndpoint, + OrganizationIntegrationsPermission, +) +from sentry.api.serializers import serialize +from sentry.constants import ObjectStatus +from sentry.exceptions import NotRegistered +from sentry.integrations.manager import default_manager as integrations +from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.integrations.pipeline import ensure_integration +from sentry.integrations.services.integration import integration_service +from sentry.models.organizationmapping import OrganizationMapping +from sentry.organizations.services.organization import RpcOrganization, RpcUserOrganizationContext + + +@control_silo_endpoint +class OrganizationIntegrationDirectEnableEndpoint(ControlSiloOrganizationEndpoint): + owner = ApiOwner.INTEGRATIONS + publish_status = { + "POST": ApiPublishStatus.PRIVATE, + } + permission_classes = (OrganizationIntegrationsPermission,) + + def post( + self, + request: Request, + organization_context: RpcUserOrganizationContext, + organization: RpcOrganization, + provider_key: str, + **kwargs: Any, + ) -> Response: + """Directly install an integration that requires no pipeline configuration.""" + try: + provider = integrations.get(provider_key) + except NotRegistered: + return Response({"detail": "Provider not found."}, status=404) + + if not (provider.metadata and provider.metadata.aspects.get("directEnable")): + return Response( + {"detail": "Direct enable is not supported for this integration."}, status=400 + ) + + if provider.requires_feature_flag: + flag = ( + provider.feature_flag_name + or "organizations:integrations-%s" % provider.key.replace("_", "-") + ) + if not features.has(flag, organization, actor=request.user): + return Response({"detail": "Provider not found."}, status=404) + + if not provider.allow_multiple: + existing = integration_service.get_integrations( + organization_id=organization.id, + providers=[provider_key], + status=ObjectStatus.ACTIVE, + ) + if existing: + return Response({"detail": "Integration is already enabled."}, status=400) + + try: + with transaction.atomic(using=router.db_for_write(OrganizationIntegration)): + if not provider.allow_multiple: + OrganizationMapping.objects.select_for_update().filter( + organization_id=organization.id + ).exists() + + existing = integration_service.get_integrations( + organization_id=organization.id, + providers=[provider_key], + status=ObjectStatus.ACTIVE, + ) + if existing: + return Response({"detail": "Integration is already enabled."}, status=400) + + data = provider.build_integration({}) + integration = ensure_integration(provider.key, data) + + user = request.user if request.user.is_authenticated else None + org_integration = integration.add_organization(organization, user) + if org_integration is None: + raise IntegrityError + except IntegrityError: + return Response({"detail": "Could not create the integration."}, status=400) + + provider.create_audit_log_entry(integration, organization, request, "install") + return Response(serialize(org_integration, request.user)) diff --git a/src/sentry/integrations/base.py b/src/sentry/integrations/base.py index 18eabce9b617..197f79b69ddf 100644 --- a/src/sentry/integrations/base.py +++ b/src/sentry/integrations/base.py @@ -256,6 +256,9 @@ def is_cell_restricted(self) -> bool: requires_feature_flag = False """if this is hidden without the feature flag""" + feature_flag_name: str | None = None + """override the feature flag checked when requires_feature_flag is True""" + @classmethod def get_installation( cls, model: RpcIntegration | Integration, organization_id: int, **kwargs: Any diff --git a/src/sentry/integrations/github_copilot/__init__.py b/src/sentry/integrations/github_copilot/__init__.py index eba56ac42116..d0f0df4eaaad 100644 --- a/src/sentry/integrations/github_copilot/__init__.py +++ b/src/sentry/integrations/github_copilot/__init__.py @@ -1,5 +1,11 @@ from sentry.integrations.github_copilot.client import GithubCopilotAgentClient +from sentry.integrations.github_copilot.integration import ( + GithubCopilotIntegration, + GithubCopilotIntegrationProvider, +) __all__ = [ "GithubCopilotAgentClient", + "GithubCopilotIntegration", + "GithubCopilotIntegrationProvider", ] diff --git a/src/sentry/integrations/github_copilot/integration.py b/src/sentry/integrations/github_copilot/integration.py new file mode 100644 index 000000000000..b2c713ee8b91 --- /dev/null +++ b/src/sentry/integrations/github_copilot/integration.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from typing import Any + +from django.utils.translation import gettext_lazy as _ + +from sentry.integrations.base import ( + FeatureDescription, + IntegrationData, + IntegrationFeatures, + IntegrationInstallation, + IntegrationMetadata, +) +from sentry.integrations.coding_agent.integration import CodingAgentIntegrationProvider + +DESCRIPTION = """ +Allow users in your Sentry organization to send issues to GitHub Copilot agents. +Each user authenticates with their own GitHub account — no org-wide credentials are required. +""" + +FEATURES = [ + FeatureDescription( + "Allow users to send Seer root cause analysis to GitHub Copilot agents.", + IntegrationFeatures.CODING_AGENT, + ), +] + +metadata = IntegrationMetadata( + description=DESCRIPTION.strip(), + features=FEATURES, + author="The Sentry Team", + noun=_("Integration"), + issue_url="https://github.com/getsentry/sentry/issues/new?assignees=&labels=Component:%20Integrations&template=bug.yml&title=GitHub%20Copilot%20Integration%20Problem", + source_url="https://github.com/getsentry/sentry/tree/master/src/sentry/integrations/github_copilot", + aspects={"directEnable": True}, +) + + +class GithubCopilotIntegration(IntegrationInstallation): + """ + Minimal installation — GitHub Copilot uses per-user OAuth tokens, + not org-wide credentials, so there is nothing to configure here. + """ + + def get_client(self) -> None: + raise NotImplementedError("GitHub Copilot uses per-user OAuth, not an org-level client") + + +class GithubCopilotIntegrationProvider(CodingAgentIntegrationProvider): + key = "github_copilot" + name = "GitHub Copilot" + metadata = metadata + integration_cls = GithubCopilotIntegration + feature_flag_name = "organizations:integrations-github-copilot-agent" + + def get_agent_name(self) -> str: + return "GitHub Copilot" + + def get_agent_key(self) -> str: + return "github_copilot" + + def get_pipeline_views(self) -> list[Any]: + return [] + + def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: + return { + "name": "GitHub Copilot", + "external_id": str(uuid.uuid4()), + "metadata": {}, + } diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index 292670e79e5b..5febeeeb7d10 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -9,9 +9,12 @@ from django.http.request import HttpRequest from django.http.response import HttpResponseBase from django.utils.translation import gettext_lazy as _ +from rest_framework.fields import BooleanField, CharField, URLField from sentry import features +from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer from sentry.identity.gitlab.provider import GitlabIdentityProvider, get_oauth_data, get_user_info +from sentry.identity.oauth2 import OAuth2ApiStep from sentry.identity.pipeline import IdentityPipeline from sentry.integrations.base import ( FeatureDescription, @@ -37,7 +40,8 @@ from sentry.models.pullrequest import PullRequest from sentry.models.repository import Repository from sentry.organizations.services.organization import organization_service -from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.types import PipelineStepResult +from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.pipeline.views.nested import NestedPipelineView from sentry.shared_integrations.exceptions import ( ApiError, @@ -393,6 +397,78 @@ def get_comment_data( } +class InstallationConfigSerializer(CamelSnakeSerializer): + url = URLField(required=False, default="https://gitlab.com") + group = CharField(required=False, allow_blank=True, default="") + include_subgroups = BooleanField(required=False, default=False) + verify_ssl = BooleanField(required=False, default=True) + client_id = CharField(required=True) + client_secret = CharField(required=True) + + +class InstallationConfigApiStep: + """ + Collects GitLab instance configuration: URL, group path, OAuth + credentials, and SSL/subgroup preferences. + + On POST, validates the form data, binds ``installation_data`` and + ``oauth_config_information`` to pipeline state, then advances. + """ + + step_name = "installation_config" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + return { + "defaults": { + "verifySsl": True, + "includeSubgroups": False, + }, + "setupValues": [ + {"label": "Name", "value": "Sentry"}, + { + "label": "Redirect URI", + "value": absolute_uri("/extensions/gitlab/setup/"), + }, + {"label": "Scopes", "value": "api"}, + ], + } + + def get_serializer_cls(self) -> type: + return InstallationConfigSerializer + + def handle_post( + self, + validated_data: dict[str, Any], + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + # Strip trailing slash from URL (same as InstallationForm.clean_url) + validated_data["url"] = validated_data["url"].rstrip("/") + + pipeline.bind_state("installation_data", validated_data) + + pipeline.bind_state( + "oauth_config_information", + { + "access_token_url": f"{validated_data['url']}/oauth/token", + "authorize_url": f"{validated_data['url']}/oauth/authorize", + "client_id": validated_data["client_id"], + "client_secret": validated_data["client_secret"], + "verify_ssl": validated_data["verify_ssl"], + }, + ) + + pipeline.get_logger().info( + "gitlab.setup.installation-config-api-step.success", + extra={ + "base_url": validated_data["url"], + "client_id": validated_data["client_id"], + "verify_ssl": validated_data["verify_ssl"], + }, + ) + return PipelineStepResult.advance() + + class InstallationForm(forms.Form): url = forms.CharField( label=_("GitLab URL"), @@ -604,8 +680,35 @@ def get_pipeline_views( lambda: self._make_identity_pipeline_view(), ) + def _make_oauth_api_step(self) -> OAuth2ApiStep: + oauth_info = self.pipeline._fetch_state("oauth_config_information") + if oauth_info is None: + raise AssertionError("pipeline called out of order") + return OAuth2ApiStep( + authorize_url=oauth_info["authorize_url"], + client_id=oauth_info["client_id"], + client_secret=oauth_info["client_secret"], + access_token_url=oauth_info["access_token_url"], + scope=" ".join(sorted(GitlabIdentityProvider.oauth_scopes)), + redirect_url=absolute_uri("/extensions/gitlab/setup/"), + verify_ssl=oauth_info.get("verify_ssl", True), + bind_key="oauth_data", + ) + + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + return [ + InstallationConfigApiStep(), + lambda: self._make_oauth_api_step(), + ] + def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: - data = state["identity"]["data"] + # TODO: legacy views write token data to state["identity"]["data"] via + # NestedPipelineView. API steps write directly to state["oauth_data"]. + # Remove the legacy path once the old views are retired. + if "oauth_data" in state: + data = state["oauth_data"] + else: + data = state["identity"]["data"] # Gitlab requires the client_id and client_secret for refreshing the access tokens oauth_config = state.get("oauth_config_information", {}) diff --git a/src/sentry/middleware/integrations/parsers/discord.py b/src/sentry/middleware/integrations/parsers/discord.py index 6d3d8eb9721f..390e84ff8c1d 100644 --- a/src/sentry/middleware/integrations/parsers/discord.py +++ b/src/sentry/middleware/integrations/parsers/discord.py @@ -64,7 +64,7 @@ def get_async_cell_response(self, cells: Sequence[Cell]) -> HttpResponse: if self.discord_request: convert_to_async_discord_response.apply_async( kwargs={ - "region_names": [c.name for c in cells], + "cell_names": [c.name for c in cells], "payload": create_async_request_payload(self.request), "response_url": self.discord_request.response_url, } diff --git a/src/sentry/middleware/integrations/parsers/slack.py b/src/sentry/middleware/integrations/parsers/slack.py index 38c88ff61775..06f26baf8273 100644 --- a/src/sentry/middleware/integrations/parsers/slack.py +++ b/src/sentry/middleware/integrations/parsers/slack.py @@ -180,7 +180,7 @@ def get_async_cell_response(self, cells: Sequence[Cell]) -> HttpResponseBase: convert_to_async_slack_response.apply_async( kwargs={ - "region_names": [r.name for r in cells], + "cell_names": [r.name for r in cells], "payload": create_async_request_payload(self.request), "response_url": self.response_url, } diff --git a/src/sentry/migrations/1059_add_groupopenperiodactivity_date_added_index.py b/src/sentry/migrations/1059_add_groupopenperiodactivity_date_added_index.py new file mode 100644 index 000000000000..d68abbcee833 --- /dev/null +++ b/src/sentry/migrations/1059_add_groupopenperiodactivity_date_added_index.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.12 on 2026-03-31 22:06 + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +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 = True + + dependencies = [ + ("sentry", "1058_change_code_mapping_unique_constraint"), + ] + + operations = [ + migrations.AddIndex( + model_name="groupopenperiodactivity", + index=models.Index(fields=["date_added"], name="sentry_grou_date_ad_e242e5_idx"), + ), + ] diff --git a/src/sentry/migrations/1060_eventattachment_date_expires.py b/src/sentry/migrations/1060_eventattachment_date_expires.py new file mode 100644 index 000000000000..e238ab8a268a --- /dev/null +++ b/src/sentry/migrations/1060_eventattachment_date_expires.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.12 on 2026-04-01 07:22 + +import datetime + +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +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", "1059_add_groupopenperiodactivity_date_added_index"), + ] + + operations = [ + migrations.AddField( + model_name="eventattachment", + name="date_expires", + field=models.DateTimeField( + db_default=datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), + ), + ), + ] diff --git a/src/sentry/migrations/1061_eventattachment_date_expires_index.py b/src/sentry/migrations/1061_eventattachment_date_expires_index.py new file mode 100644 index 000000000000..72d7de02889f --- /dev/null +++ b/src/sentry/migrations/1061_eventattachment_date_expires_index.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.12 on 2026-04-01 12:13 + +import datetime +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration + + +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 = True + + dependencies = [ + ("sentry", "1060_eventattachment_date_expires"), + ] + + operations = [ + migrations.AlterField( + model_name="eventattachment", + name="date_expires", + field=models.DateTimeField( + db_default=datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc), + db_index=True, + ), + ), + ] diff --git a/src/sentry/migrations/1062_backfill_eventattachment_date_expires.py b/src/sentry/migrations/1062_backfill_eventattachment_date_expires.py new file mode 100644 index 000000000000..543a4aee51e1 --- /dev/null +++ b/src/sentry/migrations/1062_backfill_eventattachment_date_expires.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone + +import itertools + +import click +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor +from django.db.migrations.state import StateApps +from django.db.models import ExpressionWrapper, F +from django.db.models.fields import DateTimeField + +from sentry.new_migrations.migrations import CheckedMigration + +BATCH_SIZE = 10_000 +SENTINEL = datetime(1970, 1, 1, 0, 0, 0, tzinfo=dt_timezone.utc) +DEFAULT_RETENTION_DAYS = 30 + + +def backfill_eventattachment_date_expires( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + EventAttachment = apps.get_model("sentry", "EventAttachment") + + total_updated = 0 + for i in itertools.count(): + batch = EventAttachment.objects.filter(date_expires=SENTINEL).values("id")[:BATCH_SIZE] + updated = EventAttachment.objects.filter(id__in=batch).update( + date_expires=ExpressionWrapper( + F("date_added") + timedelta(days=DEFAULT_RETENTION_DAYS), + output_field=DateTimeField(), + ) + ) + if not updated: + break + + total_updated += updated + if i % 100 == 0: + click.echo(f"Backfilled {total_updated} rows so far...") + + click.echo(f"Done. Backfilled {total_updated} rows total.") + + +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 = True + + dependencies = [ + ("sentry", "1061_eventattachment_date_expires_index"), + ] + + operations = [ + migrations.RunPython( + backfill_eventattachment_date_expires, + migrations.RunPython.noop, + hints={"tables": ["sentry_eventattachment"]}, + ), + ] diff --git a/src/sentry/models/eventattachment.py b/src/sentry/models/eventattachment.py index 82390c0d033d..d556750ede0b 100644 --- a/src/sentry/models/eventattachment.py +++ b/src/sentry/models/eventattachment.py @@ -3,6 +3,8 @@ import mimetypes import os from dataclasses import dataclass +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone from hashlib import sha1 from io import BytesIO from typing import IO, Any @@ -10,7 +12,9 @@ import zstandard from django.core.cache import cache from django.db import models +from django.db.models.expressions import DatabaseDefault from django.utils import timezone +from objectstore_client import TimeToLive from sentry.attachments.base import CachedAttachment from sentry.backup.scopes import RelocationScope @@ -18,10 +22,13 @@ from sentry.db.models.fields.bounded import BoundedIntegerField from sentry.db.models.manager.base_query_set import BaseQuerySet from sentry.models.files.utils import get_size_and_checksum, get_storage -from sentry.objectstore import get_attachments_session +from sentry.objectstore import default_attachment_retention, get_attachments_session from sentry.objectstore.metrics import measure_storage_operation from sentry.options.rollout import in_random_rollout +# Sentinel value stored in `date_expires` to mean "no explicit expiry — use default retention". +DATE_EXPIRES_SENTINEL = datetime(1970, 1, 1, 0, 0, 0, tzinfo=dt_timezone.utc) + # Attachment file types that are considered a crash report (PII relevant) CRASH_REPORT_TYPES = ("event.minidump", "event.applecrashreport") @@ -98,6 +105,10 @@ class EventAttachment(Model): sha1 = models.CharField(max_length=40, null=True) date_added = models.DateTimeField(default=timezone.now, db_index=True) + date_expires = models.DateTimeField( + db_default=DATE_EXPIRES_SENTINEL, + db_index=True, + ) # storage: blob_path = models.TextField(null=True) @@ -112,6 +123,13 @@ class Meta: __repr__ = sane_repr("event_id", "name") + def save(self, *args: Any, **kwargs: Any) -> None: + # Computed here rather than as a field default to avoid freezing a callable + # reference into migrations, which would break if the function is ever renamed. + if self.date_expires is None or isinstance(self.date_expires, DatabaseDefault): # type: ignore[unreachable] + self.date_expires = timezone.now() + timedelta(days=default_attachment_retention()) # type: ignore[unreachable] + super().save(*args, **kwargs) + def delete(self, *args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]: rv = super().delete(*args, **kwargs) @@ -219,7 +237,11 @@ def putfile(cls, project_id: int, attachment: CachedAttachment) -> PutfileResult else: organization_id = _get_organization(project_id) - blob_path = V2_PREFIX + get_attachments_session(organization_id, project_id).put(data) + session = get_attachments_session(organization_id, project_id) + key = session.put( + data, expiration_policy=TimeToLive(timedelta(days=attachment.retention_days)) + ) + blob_path = V2_PREFIX + key return PutfileResult( content_type=content_type, size=size, sha1=checksum, blob_path=blob_path diff --git a/src/sentry/models/groupopenperiodactivity.py b/src/sentry/models/groupopenperiodactivity.py index ddb5830a55bb..c9ec89e19073 100644 --- a/src/sentry/models/groupopenperiodactivity.py +++ b/src/sentry/models/groupopenperiodactivity.py @@ -48,4 +48,5 @@ class Meta: indexes = [ models.Index(fields=["group_open_period", "type", "event_id"]), models.Index(fields=["event_id"]), + models.Index(fields=["date_added"]), ] diff --git a/src/sentry/objectstore/__init__.py b/src/sentry/objectstore/__init__.py index 844037177258..d677be1d29d2 100644 --- a/src/sentry/objectstore/__init__.py +++ b/src/sentry/objectstore/__init__.py @@ -15,12 +15,26 @@ ) from objectstore_client.metrics import Tags +from sentry import options from sentry.utils import metrics as sentry_metrics from sentry.utils.env import in_test_environment __all__ = ["get_attachments_session", "parse_accept_encoding"] +def default_attachment_retention() -> int: + """ + Returns the default attachment retention in days, which is used if no + specific retention is set for an attachment. + + This is determined by the `system.event-retention-days` option, which is the + same as the default event retention. This ensures that attachments that + don't declare a retention (e.g. because of a bug) will be retained for at + least as long as the events, and not get deleted prematurely. + """ + return int(options.get("system.event-retention-days") or 0) or 30 + + class SentryMetricsBackend(MetricsBackend): def increment( self, @@ -46,10 +60,8 @@ def distribution( sentry_metrics.distribution(name, value, tags=tags, unit=unit) -_ATTACHMENTS_CLIENT: Client | None = None - _OBJECTSTORE_CLIENT: Client | None = None -_ATTACHMENTS_USECASE = Usecase("attachments", expiration_policy=TimeToLive(timedelta(days=30))) +_ATTACHMENTS_USECASE: Usecase | None = None _PREPROD_USECASE = Usecase("preprod", expiration_policy=TimeToLive(timedelta(days=30))) @@ -82,20 +94,29 @@ def create_client() -> Client: ) -def get_attachments_session(org: int, project: int) -> Session: - global _ATTACHMENTS_CLIENT - if not _ATTACHMENTS_CLIENT: - _ATTACHMENTS_CLIENT = create_client() - - return _ATTACHMENTS_CLIENT.session(_ATTACHMENTS_USECASE, org=org, project=project) - - -def get_preprod_session(org: int, project: int) -> Session: +def get_client() -> Client: global _OBJECTSTORE_CLIENT if not _OBJECTSTORE_CLIENT: _OBJECTSTORE_CLIENT = create_client() + return _OBJECTSTORE_CLIENT + + +def get_attachments_usecase() -> Usecase: + global _ATTACHMENTS_USECASE + if not _ATTACHMENTS_USECASE: + retention = default_attachment_retention() + _ATTACHMENTS_USECASE = Usecase( + "attachments", expiration_policy=TimeToLive(timedelta(days=retention)) + ) + return _ATTACHMENTS_USECASE + - return _OBJECTSTORE_CLIENT.session(_PREPROD_USECASE, org=org, project=project) +def get_attachments_session(org: int, project: int) -> Session: + return get_client().session(get_attachments_usecase(), org=org, project=project) + + +def get_preprod_session(org: int, project: int) -> Session: + return get_client().session(_PREPROD_USECASE, org=org, project=project) _IS_SYMBOLICATOR_CONTAINER: bool | None = None diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 12942114b0c1..63979642b702 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -1127,12 +1127,6 @@ type=Bool, flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, ) -register( - "seer.similarity-backfill-killswitch.enabled", - default=False, - type=Bool, - flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, -) register( "seer.similarity-embeddings-killswitch.enabled", default=False, diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_admin_batch_delete.py b/src/sentry/preprod/api/endpoints/preprod_artifact_admin_batch_delete.py index 9c50877e79c3..0319b6b6023b 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_admin_batch_delete.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_admin_batch_delete.py @@ -88,17 +88,6 @@ def delete(self, request: Request) -> Response: try: result = delete_artifacts_and_eap_data(artifacts_to_delete) - return Response( - { - "success": True, - "message": f"Successfully deleted {result.artifacts_deleted} artifacts.", - "artifact_ids": [str(artifact.id) for artifact in artifacts_to_delete], - "files_deleted": result.files_deleted, - "size_metrics_deleted": result.size_metrics_deleted, - "installable_artifacts_deleted": result.installable_artifacts_deleted, - } - ) - except Exception: logger.exception( "preprod_artifact.admin_batch_delete.artifacts_delete_failed", @@ -111,3 +100,14 @@ def delete(self, request: Request) -> Response: {"success": False, "detail": "Internal error deleting artifacts."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + + return Response( + { + "success": True, + "message": f"Successfully deleted {result.artifacts_deleted} artifacts.", + "artifact_ids": [str(artifact.id) for artifact in artifacts_to_delete], + "files_deleted": result.files_deleted, + "size_metrics_deleted": result.size_metrics_deleted, + "installable_artifacts_deleted": result.installable_artifacts_deleted, + } + ) diff --git a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py index e444ad7fbb0a..acc4bc531a02 100644 --- a/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/preprod_artifact_snapshot.py @@ -23,7 +23,10 @@ from sentry.models.organization import Organization from sentry.models.project import Project from sentry.objectstore import get_preprod_session -from sentry.preprod.analytics import PreprodArtifactApiGetSnapshotDetailsEvent +from sentry.preprod.analytics import ( + PreprodArtifactApiDeleteEvent, + PreprodArtifactApiGetSnapshotDetailsEvent, +) from sentry.preprod.api.models.project_preprod_build_details_models import ( BuildDetailsVcsInfo, ) @@ -33,6 +36,7 @@ SnapshotImageResponse, ) from sentry.preprod.api.schemas import VCS_ERROR_MESSAGES, VCS_SCHEMA_PROPERTIES +from sentry.preprod.helpers.deletion import delete_artifacts_and_eap_data from sentry.preprod.models import PreprodArtifact from sentry.preprod.snapshots.comparison_categorizer import ( CategorizedComparison, @@ -103,9 +107,64 @@ class OrganizationPreprodSnapshotEndpoint(OrganizationEndpoint): owner = ApiOwner.EMERGE_TOOLS publish_status = { "GET": ApiPublishStatus.EXPERIMENTAL, + "DELETE": ApiPublishStatus.EXPERIMENTAL, } permission_classes = (OrganizationReleasePermission,) + def delete(self, request: Request, organization: Organization, snapshot_id: str) -> Response: + if not settings.IS_DEV and not features.has( + "organizations:preprod-snapshots", organization, actor=request.user + ): + return Response({"detail": "Feature not enabled"}, status=403) + + try: + artifact = PreprodArtifact.objects.select_related("project").get( + id=snapshot_id, project__organization_id=organization.id + ) + except (PreprodArtifact.DoesNotExist, ValueError): + return Response({"detail": "Snapshot not found"}, status=404) + + try: + artifact.preprodsnapshotmetrics + except PreprodSnapshotMetrics.DoesNotExist: + return Response({"detail": "Artifact is not a snapshot"}, status=400) + + try: + result = delete_artifacts_and_eap_data([artifact]) + except Exception: + logger.exception( + "preprod_snapshot.delete_failed", + extra={"artifact_id": int(snapshot_id)}, + ) + return Response( + {"detail": "Internal error deleting snapshot."}, + status=500, + ) + + analytics.record( + PreprodArtifactApiDeleteEvent( + organization_id=organization.id, + project_id=artifact.project_id, + user_id=( + request.user.id if request.user and request.user.is_authenticated else None + ), + artifact_id=str(artifact.id), + ) + ) + + logger.info( + "preprod_snapshot.deleted", + extra={ + "artifact_id": int(snapshot_id), + "user_id": request.user.id if request.user else None, + "files_deleted": result.files_deleted, + "size_metrics_deleted": result.size_metrics_deleted, + "artifacts_deleted": result.artifacts_deleted, + }, + ) + + return Response(status=204) + def get(self, request: Request, organization: Organization, snapshot_id: str) -> Response: if not settings.IS_DEV and not features.has( "organizations:preprod-snapshots", organization, actor=request.user diff --git a/src/sentry/preprod/api/endpoints/project_preprod_artifact_delete.py b/src/sentry/preprod/api/endpoints/project_preprod_artifact_delete.py index 09b6fb90586b..add3d2d99e4b 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_artifact_delete.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_artifact_delete.py @@ -50,29 +50,6 @@ def delete( try: result = delete_artifacts_and_eap_data([head_artifact]) - - logger.info( - "preprod_artifact.deleted", - extra={ - "artifact_id": int(head_artifact_id), - "user_id": request.user.id, - "files_deleted": result.files_deleted, - "size_metrics_deleted": result.size_metrics_deleted, - "installable_artifacts_deleted": result.installable_artifacts_deleted, - }, - ) - - return Response( - { - "success": True, - "message": f"Artifact {head_artifact_id} deleted successfully.", - "artifact_id": str(head_artifact_id), - "files_deleted_count": result.files_deleted, - "size_metrics_deleted": result.size_metrics_deleted, - "installable_artifacts_deleted": result.installable_artifacts_deleted, - } - ) - except Exception: logger.exception( "preprod_artifact.delete_failed", @@ -85,3 +62,25 @@ def delete( }, status=500, ) + + logger.info( + "preprod_artifact.deleted", + extra={ + "artifact_id": int(head_artifact_id), + "user_id": request.user.id, + "files_deleted": result.files_deleted, + "size_metrics_deleted": result.size_metrics_deleted, + "installable_artifacts_deleted": result.installable_artifacts_deleted, + }, + ) + + return Response( + { + "success": True, + "message": f"Artifact {head_artifact_id} deleted successfully.", + "artifact_id": str(head_artifact_id), + "files_deleted_count": result.files_deleted, + "size_metrics_deleted": result.size_metrics_deleted, + "installable_artifacts_deleted": result.installable_artifacts_deleted, + } + ) diff --git a/src/sentry/preprod/helpers/deletion.py b/src/sentry/preprod/helpers/deletion.py index 425c60c1d59f..1206dcc9e4c3 100644 --- a/src/sentry/preprod/helpers/deletion.py +++ b/src/sentry/preprod/helpers/deletion.py @@ -4,7 +4,9 @@ from collections import defaultdict from dataclasses import dataclass +import orjson from django.db import router, transaction +from django.db.models import Q from sentry_protos.snuba.v1.endpoint_delete_trace_items_pb2 import DeleteTraceItemsRequest from sentry_protos.snuba.v1.request_common_pb2 import ( RequestMeta, @@ -15,9 +17,13 @@ from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter from sentry.models.files.file import File +from sentry.objectstore import get_preprod_session from sentry.preprod.eap.constants import get_preprod_trace_id from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics +from sentry.preprod.snapshots.manifest import ComparisonManifest +from sentry.preprod.snapshots.models import PreprodSnapshotComparison, PreprodSnapshotMetrics from sentry.utils import snuba_rpc +from sentry.utils.concurrent import ContextPropagatingThreadPoolExecutor logger = logging.getLogger(__name__) @@ -38,6 +44,8 @@ class ArtifactDeletionResult: files_deleted: int """Total number of files deleted""" + objectstore_keys_deleted: int = 0 + def bulk_delete_artifacts( preprod_artifacts: list[PreprodArtifact], @@ -98,6 +106,93 @@ def bulk_delete_artifacts( return result +def _collect_snapshot_objectstore_keys( + preprod_artifacts: list[PreprodArtifact], +) -> list[tuple[int, int, str]]: + # Collects three types of objectstore keys for the given artifacts: + # 1. Snapshot manifest keys (per-snapshot JSON manifests from PreprodSnapshotMetrics) + # 2. Comparison manifest keys (diff manifests from PreprodSnapshotComparison) + # 3. Diff mask image keys (per-image diff masks referenced within comparison manifests) + # Note: shared content-addressed image keys are NOT collected — they expire via X-day TTL. + artifact_ids = [a.id for a in preprod_artifacts] + snapshot_metrics_list = list( + PreprodSnapshotMetrics.objects.filter(preprod_artifact_id__in=artifact_ids).select_related( + "preprod_artifact__project" + ) + ) + if not snapshot_metrics_list: + return [] + + keys: list[tuple[int, int, str]] = [] + metrics_ids: list[int] = [] + + for sm in snapshot_metrics_list: + org_id = sm.preprod_artifact.project.organization_id + project_id = sm.preprod_artifact.project_id + metrics_ids.append(sm.id) + + manifest_key = (sm.extras or {}).get("manifest_key") + if not manifest_key: + continue + + # Image keys are content-addressed and shared across snapshots; + # only delete the manifest, not images (they expire via X-day TTL). + keys.append((org_id, project_id, manifest_key)) + + for comp in PreprodSnapshotComparison.objects.filter( + Q(head_snapshot_metrics_id__in=metrics_ids) | Q(base_snapshot_metrics_id__in=metrics_ids) + ).select_related("head_snapshot_metrics__preprod_artifact__project"): + org_id = comp.head_snapshot_metrics.preprod_artifact.project.organization_id + project_id = comp.head_snapshot_metrics.preprod_artifact.project_id + + comparison_key = (comp.extras or {}).get("comparison_key") + if not comparison_key: + continue + + keys.append((org_id, project_id, comparison_key)) + try: + session = get_preprod_session(org_id, project_id) + comp_manifest = ComparisonManifest( + **orjson.loads(session.get(comparison_key).payload.read()) + ) + for img in comp_manifest.images.values(): + if img.diff_mask_key: + keys.append((org_id, project_id, img.diff_mask_key)) + except Exception: + logger.exception( + "preprod.cleanup.comparison_manifest_read_failed", + extra={"comparison_key": comparison_key}, + ) + + return keys + + +def _delete_objectstore_key(args: tuple[int, int, str]) -> bool: + org_id, project_id, key = args + try: + get_preprod_session(org_id, project_id).delete(key) + return True + except Exception: + logger.exception("preprod.cleanup.objectstore_delete_failed", extra={"key": key}) + return False + + +def _delete_objectstore_keys( + keys: list[tuple[int, int, str]], +) -> int: + if not keys: + return 0 + + with ContextPropagatingThreadPoolExecutor(max_workers=8) as executor: + deleted = sum(1 for ok in executor.map(_delete_objectstore_key, keys) if ok) + + logger.info( + "preprod.cleanup.objectstore_delete_completed", + extra={"keys_deleted": deleted, "keys_total": len(keys)}, + ) + return deleted + + def delete_artifacts_and_eap_data( preprod_artifacts: list[PreprodArtifact], ) -> ArtifactDeletionResult: @@ -112,8 +207,21 @@ def delete_artifacts_and_eap_data( files_deleted=0, ) + try: + objectstore_keys = list( + dict.fromkeys(_collect_snapshot_objectstore_keys(preprod_artifacts)) + ) + except Exception: + logger.exception("preprod.cleanup.snapshot_objectstore_key_collection_failed") + objectstore_keys = [] + result = bulk_delete_artifacts(preprod_artifacts) + try: + result.objectstore_keys_deleted = _delete_objectstore_keys(objectstore_keys) + except Exception: + logger.exception("preprod.cleanup.snapshot_objectstore_delete_failed") + artifacts_by_project: defaultdict[tuple[int, int], list[int]] = defaultdict(list) for artifact in preprod_artifacts: key = (artifact.project.organization_id, artifact.project.id) diff --git a/src/sentry/preprod/size_analysis/grouptype.py b/src/sentry/preprod/size_analysis/grouptype.py index 8e0b30126029..c3a28e6fc679 100644 --- a/src/sentry/preprod/size_analysis/grouptype.py +++ b/src/sentry/preprod/size_analysis/grouptype.py @@ -81,19 +81,43 @@ def _get_measurement_label(measurement: str, platform: str) -> str: return measurement.replace("_", " ").title() +def _build_identifier_prefix(metadata: SizeAnalysisMetadata | None) -> str: + """Build an app identifier prefix like 'MyApp android (com.example.app) — '. + + Returns empty string if no metadata is available. + """ + if metadata is None: + return "" + + head_artifact = metadata["head_artifact"] + parts: list[str] = [] + + mobile_app_info = head_artifact.get_mobile_app_info() + if mobile_app_info is not None and mobile_app_info.app_name: + parts.append(mobile_app_info.app_name) + + parts.append(metadata["platform"]) + + if head_artifact.app_id: + parts.append(f"({head_artifact.app_id})") + + return " ".join(parts) + " — " + + def _build_evidence_text( detector_config: dict[str, Any], evaluation_result: ProcessedDataConditionGroup, data_packet: SizeAnalysisDataPacket, platform: str, ) -> str: - """Build a single-line evidence string for Slack/Jira notifications. + """Build evidence string for Slack/Jira notifications. - Format: {measurement}, {threshold_type} > {threshold_value} ({actual_value}) - Example: Install Size, Absolute Diff > 1.0 MB (+1.0 MB) + Format: {app_name}, {platform} ({bundle}) — {measurement}, {threshold_type} > {threshold} ({actual_value}) + Example: MyApp, android (com.example.app) — Install Size, Absolute Diff > 1.0 MB (+1.0 MB) """ from sentry.preprod.utils import format_bytes_base10 + metadata = data_packet.packet.get("metadata") measurement = detector_config["measurement"] threshold_type = detector_config["threshold_type"] measurement_label = _get_measurement_label(measurement, platform) @@ -134,7 +158,8 @@ def _build_evidence_text( sign = "+" if delta >= 0 else "-" actual_value = f"{sign}{delta_formatted}" - return f"{measurement_label}{threshold_part} ({actual_value})" + identifier = _build_identifier_prefix(metadata) + return f"{identifier}{measurement_label}{threshold_part} ({actual_value})" class SizeAnalysisMetadata(TypedDict): diff --git a/src/sentry/preprod/vcs/status_checks/size/tasks.py b/src/sentry/preprod/vcs/status_checks/size/tasks.py index 9a0c32fd8b3e..0bacfccabd91 100644 --- a/src/sentry/preprod/vcs/status_checks/size/tasks.py +++ b/src/sentry/preprod/vcs/status_checks/size/tasks.py @@ -250,6 +250,10 @@ def create_preprod_status_check_task( for approval in approval_qs: approvals_map[approval.preprod_artifact_id] = approval + # Filter out artifacts not in the size analysis pipeline (e.g., snapshot-only artifacts). + # Symmetric with snapshots/tasks.py which filters to snapshot-only artifacts. + all_artifacts = [a for a in all_artifacts if a.id in size_metrics_map] + # Filter out SKIPPED artifacts (user didn't request size analysis) all_artifacts = [ a for a in all_artifacts if not _is_artifact_size_skipped(size_metrics_map.get(a.id, [])) diff --git a/src/sentry/preprod/vcs/status_checks/snapshots/tasks.py b/src/sentry/preprod/vcs/status_checks/snapshots/tasks.py index 14e9276b85a4..fce89a853807 100644 --- a/src/sentry/preprod/vcs/status_checks/snapshots/tasks.py +++ b/src/sentry/preprod/vcs/status_checks/snapshots/tasks.py @@ -109,24 +109,6 @@ def create_preprod_snapshot_status_check_task( all_artifacts = list(preprod_artifact.get_sibling_artifacts_for_commit()) - client, repository = get_status_check_client(preprod_artifact.project, commit_comparison) - if not client or not repository: - return - - provider = get_status_check_provider( - client, - commit_comparison.provider, - preprod_artifact.project.organization_id, - preprod_artifact.project.organization.slug, - repository.integration_id, - ) - if not provider: - logger.info( - "preprod.snapshot_status_checks.create.not_supported_provider", - extra={"provider": commit_comparison.provider}, - ) - return - artifact_ids = [a.id for a in all_artifacts] snapshot_metrics_qs = PreprodSnapshotMetrics.objects.filter( preprod_artifact_id__in=artifact_ids, @@ -163,6 +145,50 @@ def create_preprod_snapshot_status_check_task( base_artifact_map = PreprodArtifact.get_base_artifacts_for_commit(all_artifacts) is_solo = not base_artifact_map + + if not is_solo: + changes_map = _build_changes_map( + all_artifacts, + snapshot_metrics_map, + comparisons_map, + fail_on_added=fail_on_added, + fail_on_removed=fail_on_removed, + ) + for artifact in all_artifacts: + if changes_map.get(artifact.id, False) and artifact.id not in approvals_map: + # exists()+create() instead of get_or_create: no unique constraint + # on this model, so duplicates from races are harmless (cleaned + # up by filter().delete()), while get_or_create would crash with + # MultipleObjectsReturned if duplicates already exist. + if not PreprodComparisonApproval.objects.filter( + preprod_artifact=artifact, + preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS, + approval_status=PreprodComparisonApproval.ApprovalStatus.NEEDS_APPROVAL, + ).exists(): + PreprodComparisonApproval.objects.create( + preprod_artifact=artifact, + preprod_feature_type=PreprodComparisonApproval.FeatureType.SNAPSHOTS, + approval_status=PreprodComparisonApproval.ApprovalStatus.NEEDS_APPROVAL, + ) + + client, repository = get_status_check_client(preprod_artifact.project, commit_comparison) + if not client or not repository: + return + + provider = get_status_check_provider( + client, + commit_comparison.provider, + preprod_artifact.project.organization_id, + preprod_artifact.project.organization.slug, + repository.integration_id, + ) + if not provider: + logger.info( + "preprod.snapshot_status_checks.create.not_supported_provider", + extra={"provider": commit_comparison.provider}, + ) + return + approve_action_identifier: str | None = None if is_solo: @@ -198,13 +224,6 @@ def create_preprod_snapshot_status_check_task( snapshot_metrics_map, ) else: - changes_map = _build_changes_map( - all_artifacts, - snapshot_metrics_map, - comparisons_map, - fail_on_added=fail_on_added, - fail_on_removed=fail_on_removed, - ) status = _compute_snapshot_status( all_artifacts, snapshot_metrics_map, @@ -212,6 +231,7 @@ def create_preprod_snapshot_status_check_task( approvals_map, changes_map, ) + title, subtitle, summary = format_snapshot_status_check_messages( all_artifacts, snapshot_metrics_map, diff --git a/src/sentry/preprod/vcs/webhooks/github_check_run.py b/src/sentry/preprod/vcs/webhooks/github_check_run.py index 2ce271033837..b706c954dabb 100644 --- a/src/sentry/preprod/vcs/webhooks/github_check_run.py +++ b/src/sentry/preprod/vcs/webhooks/github_check_run.py @@ -183,6 +183,13 @@ def handle_preprod_check_run_event( ) approvals_created += 1 + if approvals_created > 0: + PreprodComparisonApproval.objects.filter( + preprod_artifact_id__in=sibling_ids, + preprod_feature_type=feature_type, + approval_status=PreprodComparisonApproval.ApprovalStatus.NEEDS_APPROVAL, + ).delete() + logger.info( Log.APPROVALS_CREATED, extra={ diff --git a/src/sentry/processing_errors/eap/producer.py b/src/sentry/processing_errors/eap/producer.py index adc56b9d6db6..0d3793115aa5 100644 --- a/src/sentry/processing_errors/eap/producer.py +++ b/src/sentry/processing_errors/eap/producer.py @@ -19,6 +19,7 @@ from sentry.utils.arroyo_producer import SingletonProducer, get_arroyo_producer from sentry.utils.eap import hex_to_item_id from sentry.utils.kafka_config import get_topic_definition +from sentry.utils.safe import get_path if TYPE_CHECKING: from sentry.models.project import Project @@ -51,7 +52,7 @@ def produce_processing_errors_to_eap( stored as attributes. This enables querying processing errors in EAP for configuration issue detection. """ - trace_id = event_data.get("contexts", {}).get("trace", {}).get("trace_id") + trace_id = get_path(event_data, "contexts", "trace", "trace_id") if trace_id is None: logger.debug("Skipping EAP processing error production: missing trace_id") return diff --git a/src/sentry/reprocessing2.py b/src/sentry/reprocessing2.py index 52a4d1f3b632..81f5f8299c28 100644 --- a/src/sentry/reprocessing2.py +++ b/src/sentry/reprocessing2.py @@ -89,21 +89,22 @@ import logging from collections.abc import Mapping, MutableMapping from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Literal, overload import sentry_sdk from django.conf import settings from django.db import router from django.utils import timezone +from objectstore_client import TimeToLive -from sentry import models, nodestore, options +from sentry import models, nodestore, options, quotas from sentry.attachments import CachedAttachment, attachment_cache, store_attachments_for_event from sentry.deletions.defaults.group import DIRECT_GROUP_RELATED_MODELS from sentry.models.eventattachment import V1_PREFIX, V2_PREFIX, EventAttachment from sentry.models.files.utils import get_storage from sentry.models.project import Project -from sentry.objectstore import get_attachments_session +from sentry.objectstore import default_attachment_retention, get_attachments_session from sentry.options.rollout import in_random_rollout from sentry.search.eap.occurrences.common_queries import count_occurrences from sentry.search.eap.occurrences.rollout_utils import EAPOccurrencesComparator @@ -419,9 +420,15 @@ def _maybe_copy_attachment_into_cache( # attachment is already in objectstore (regardless of flag) stored_id = blob_path.removeprefix(V2_PREFIX) elif in_random_rollout("objectstore.enable_for.attachments"): + retention_days = ( + quotas.backend.get_event_retention(project.organization) + or default_attachment_retention() + ) # move the attachment into objectstore and update the record with attachment.getfile() as fp: - stored_id = get_attachments_session(project.organization_id, project.id).put(fp) + stored_id = get_attachments_session(project.organization_id, project.id).put( + fp, expiration_policy=TimeToLive(timedelta(days=retention_days)) + ) attachment.blob_path = V2_PREFIX + stored_id attachment.save() if blob_path.startswith(V1_PREFIX): diff --git a/src/sentry/runner/commands/deletions.py b/src/sentry/runner/commands/deletions.py new file mode 100644 index 000000000000..311873e2bf42 --- /dev/null +++ b/src/sentry/runner/commands/deletions.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import click + +from sentry.runner.decorators import configuration + +if TYPE_CHECKING: + from sentry.deletions.models.scheduleddeletion import BaseScheduledDeletion + + +def _query_all(**filters: Any) -> list[BaseScheduledDeletion]: + from sentry.deletions.models.scheduleddeletion import CellScheduledDeletion, ScheduledDeletion + from sentry.silo.base import SiloLimit + + results: list[BaseScheduledDeletion] = [] + for model_cls in [ScheduledDeletion, CellScheduledDeletion]: + try: + results.extend(model_cls.objects.filter(**filters)) + except SiloLimit.AvailabilityError: + continue + return results + + +@click.group() +def deletions() -> None: + """ + Utilities to manage scheduled deletions locally. + """ + + +@deletions.command("list") +@click.option( + "-m", + "--model", + help="Filter by model name (e.g. OrganizationIntegration)", + default=None, +) +@configuration +def list_deletions(model: str | None) -> None: + """ + List pending scheduled deletions. + """ + filters: dict[str, Any] = {} + if model: + filters["model_name"] = model + + deletions_list = _query_all(**filters) + if not deletions_list: + click.echo("No pending deletions found.") + return + + click.echo( + f"\n{'Table':<26} {'ID':<8} {'Model':<30} {'Object ID':<12} {'In Progress':<14} {'Scheduled'}" + ) + click.echo("-" * 116) + for d in deletions_list: + click.echo( + f"{type(d).__name__:<26} {d.id:<8} {d.model_name:<30} {d.object_id:<12} {str(d.in_progress):<14} {d.date_scheduled}" + ) + + +@deletions.command("run") +@click.option( + "-i", "--id", "deletion_id", type=int, help="ScheduledDeletion object ID to run", default=None +) +@click.option( + "--cid", + "cell_deletion_id", + type=int, + help="CellScheduledDeletion object ID to run", + default=None, +) +@click.option( + "-m", + "--model", + help="Run all pending deletions for a model name (e.g. OrganizationIntegration)", + default=None, +) +@click.option("--all", "run_all", is_flag=True, help="Run all pending deletions") +@click.option("-v", "--verbose", is_flag=True, help="Show full tracebacks on failure") +@configuration +def run_deletions( + deletion_id: int | None, + cell_deletion_id: int | None, + model: str | None, + run_all: bool, + verbose: bool, +) -> None: + """ + Run pending scheduled deletions synchronously. + """ + from django.utils import timezone + + if not any([deletion_id, cell_deletion_id, model, run_all]): + raise click.UsageError( + "Provide one of: --id, --cid, --model, or --all. " + "Use `sentry deletions list` to see pending deletions." + ) + + if deletion_id or cell_deletion_id: + from sentry.deletions.models.scheduleddeletion import ( + CellScheduledDeletion, + ScheduledDeletion, + ) + from sentry.silo.base import SiloLimit + + model_cls: type[BaseScheduledDeletion] + if cell_deletion_id: + model_cls, target_id = CellScheduledDeletion, cell_deletion_id + else: + assert deletion_id is not None + model_cls, target_id = ScheduledDeletion, deletion_id + + try: + deletion = model_cls.objects.get(id=target_id) + except ( + ScheduledDeletion.DoesNotExist, + CellScheduledDeletion.DoesNotExist, + SiloLimit.AvailabilityError, + ): + click.echo(f"Deletion with ID {target_id} not found in {model_cls.__name__}.") + return + _run_one(deletion=deletion, verbose=verbose) + return + + filters: dict[str, Any] = { + "in_progress": False, + "date_scheduled__lte": timezone.now(), + } + if model: + filters["model_name"] = model + + deletions_list = _query_all(**filters) + if not deletions_list: + click.echo("No pending deletions found.") + return + + click.echo(f"Running {len(deletions_list)} deletion(s)...") + for d in deletions_list: + _run_one(deletion=d, verbose=verbose) + + +def _run_one(*, deletion: BaseScheduledDeletion, verbose: bool = False) -> None: + from django.core.exceptions import ObjectDoesNotExist + + from sentry import deletions as deletions_module + from sentry.signals import pending_delete + + click.echo(f"Running deletion {deletion.id} ({deletion.model_name} #{deletion.object_id})...") + try: + instance = deletion.get_instance() + except ObjectDoesNotExist: + click.echo(" Object already deleted, cleaning up scheduled deletion.") + deletion.delete() + return + + task = deletions_module.get( + model=deletion.get_model(), + query={"id": deletion.object_id}, + transaction_id=deletion.guid, + actor_id=deletion.actor_id, + ) + + if not task.should_proceed(instance): + click.echo(" Deletion aborted (should_proceed returned False).") + deletion.delete() + return + + actor = deletion.get_actor() + pending_delete.send(sender=type(instance), instance=instance, actor=actor) + + try: + has_more = True + while has_more: + has_more = task.chunk() + deletion.delete() + click.echo(" Done.") + except Exception as e: + if verbose: + import traceback + + click.echo(f" Failed:\n{traceback.format_exc()}", err=True) + else: + click.echo(f" Failed: {e}", err=True) + click.echo(" Deletion record preserved for retry. Re-run to continue.") diff --git a/src/sentry/runner/main.py b/src/sentry/runner/main.py index 425905244bb7..4c7f0f70bd8f 100644 --- a/src/sentry/runner/main.py +++ b/src/sentry/runner/main.py @@ -70,6 +70,7 @@ def cli(config: str) -> None: "sentry.runner.commands.spans.spans", "sentry.runner.commands.spans.write_hashes", "sentry.runner.commands.notifications.notifications", + "sentry.runner.commands.deletions.deletions", ), ): cli.add_command(cmd) diff --git a/src/sentry/search/eap/columns.py b/src/sentry/search/eap/columns.py index 7648645f6fe7..c53a264dd99c 100644 --- a/src/sentry/search/eap/columns.py +++ b/src/sentry/search/eap/columns.py @@ -17,7 +17,7 @@ Function, VirtualColumnContext, ) -from sentry_protos.snuba.v1.trace_item_filter_pb2 import TraceItemFilter +from sentry_protos.snuba.v1.trace_item_filter_pb2 import AndFilter, TraceItemFilter from sentry.api.event_search import SearchFilter from sentry.exceptions import InvalidSearchQuery @@ -27,7 +27,7 @@ from sentry.search.eap.types import EAPResponse, SearchResolverConfig from sentry.search.events.types import SnubaParams -ResolvedArgument: TypeAlias = AttributeKey | str | int | float +ResolvedArgument: TypeAlias = AttributeKey | str | int | float | TraceItemFilter ResolvedArguments: TypeAlias = list[ResolvedArgument] ProtoDefinition: TypeAlias = ( LiteralValue @@ -144,7 +144,7 @@ class BaseArgumentDefinition: @dataclass class ValueArgumentDefinition(BaseArgumentDefinition): # the type of the argument itself, if the type is a non-string you should ensure an appropriate validator is provided to avoid conversion errors - argument_types: set[Literal["integer", "string", "number"]] | None = None + argument_types: set[Literal["integer", "string", "number", "query"]] | None = None @dataclass @@ -270,13 +270,38 @@ def proto_definition(self) -> AttributeAggregation | AttributeConditionalAggrega ) +@dataclass(frozen=True, kw_only=True) +class ResolvedConditionalTraceMetricAggregate(ResolvedTraceMetricAggregate): + trace_filter: TraceItemFilter + + @property + def proto_definition(self) -> AttributeAggregation | AttributeConditionalAggregation: + if self.trace_metric is None: + return AttributeConditionalAggregation( + aggregate=self.internal_name, + key=self.key, + filter=self.trace_filter, + label=self.public_alias, + extrapolation_mode=self.extrapolation_mode, + ) + return AttributeConditionalAggregation( + aggregate=self.internal_name, + key=self.key, + filter=TraceItemFilter( + and_filter=AndFilter(filters=[self.trace_metric.get_filter(), self.trace_filter]) + ), + label=self.public_alias, + extrapolation_mode=self.extrapolation_mode, + ) + + @dataclass(frozen=True, kw_only=True) class ResolvedConditionalAggregate(ResolvedFunction): # The internal rpc alias for this column internal_name: Function.ValueType extrapolation_mode: ExtrapolationMode.ValueType # The condition to filter on - filter: TraceItemFilter + trace_filter: TraceItemFilter # The attribute to conditionally aggregate on key: AttributeKey @@ -288,7 +313,7 @@ def proto_definition(self) -> AttributeConditionalAggregation: return AttributeConditionalAggregation( aggregate=self.internal_name, key=self.key, - filter=self.filter, + filter=self.trace_filter, label=self.public_alias, extrapolation_mode=self.extrapolation_mode, ) @@ -395,8 +420,11 @@ def resolve( @dataclass(kw_only=True) class TraceMetricAggregateDefinition(AggregateDefinition): + offset = 0 + expected_args = 4 + def __post_init__(self) -> None: - validate_trace_metric_aggregate_arguments(self.arguments) + validate_trace_metric_aggregate_arguments(self.arguments, self.offset, self.expected_args) def resolve( self, @@ -407,16 +435,39 @@ def resolve( query_result_cache: dict[str, EAPResponse], search_config: SearchResolverConfig, ) -> ResolvedFunction: - if not isinstance(resolved_arguments[0], AttributeKey): + return self._resolve( + alias, + search_type, + resolved_arguments, + snuba_params, + query_result_cache, + search_config, + ResolvedTraceMetricAggregate, + ) + + def _resolve( + self, + alias: str, + search_type: constants.SearchType, + resolved_arguments: ResolvedArguments, + snuba_params: SnubaParams, + query_result_cache: dict[str, EAPResponse], + search_config: SearchResolverConfig, + aggregate: type[ResolvedConditionalTraceMetricAggregate] + | type[ResolvedTraceMetricAggregate], + ) -> ResolvedFunction: + if not isinstance(resolved_arguments[self.offset], AttributeKey): raise InvalidSearchQuery( - "Trace metric aggregates expect argument 0 to be of type AttributeArgumentDefinition" + f"Trace metric aggregates expect argument {self.offset} to be of type AttributeArgumentDefinition" ) - resolved_attribute = resolved_arguments[0] + resolved_attribute = resolved_arguments[self.offset] if self.attribute_resolver is not None: resolved_attribute = self.attribute_resolver(resolved_attribute) - trace_metric = extract_trace_metric_aggregate_arguments(resolved_arguments) + trace_metric = extract_trace_metric_aggregate_arguments( + resolved_arguments, offset=self.offset + ) # The search type for the first argument is always going to be 'number', but # the real unit for the metric is stored in the metric_unit field of the trace metric @@ -432,17 +483,46 @@ def resolve( ): resolved_search_type = cast(constants.SearchType, trace_metric.metric_unit) - return ResolvedTraceMetricAggregate( - public_alias=alias, - internal_name=self.internal_function, - search_type=resolved_search_type, - internal_type=self.internal_type, - processor=self.processor, - extrapolation_mode=resolve_extrapolation_mode( + kwargs = { + "public_alias": alias, + "internal_name": self.internal_function, + "search_type": resolved_search_type, + "internal_type": self.internal_type, + "processor": self.processor, + "extrapolation_mode": resolve_extrapolation_mode( search_config, self.extrapolation_mode_override ), - key=resolved_attribute, - trace_metric=trace_metric, + "key": resolved_attribute, + "trace_metric": trace_metric, + } + if aggregate == ResolvedConditionalTraceMetricAggregate: + kwargs["trace_filter"] = resolved_arguments[0] + + return aggregate(**kwargs) # type: ignore[arg-type] + + +@dataclass(kw_only=True) +class ConditionalTraceMetricAggregateDefinition(TraceMetricAggregateDefinition): + offset = 1 + expected_args = 5 + + def resolve( + self, + alias: str, + search_type: constants.SearchType, + resolved_arguments: ResolvedArguments, + snuba_params: SnubaParams, + query_result_cache: dict[str, EAPResponse], + search_config: SearchResolverConfig, + ) -> ResolvedFunction: + return self._resolve( + alias, + search_type, + resolved_arguments, + snuba_params, + query_result_cache, + search_config, + ResolvedConditionalTraceMetricAggregate, ) @@ -473,7 +553,7 @@ def resolve( internal_name=self.internal_function, search_type=search_type, internal_type=self.internal_type, - filter=aggregate_filter, + trace_filter=aggregate_filter, key=key, processor=self.processor, extrapolation_mode=resolve_extrapolation_mode( @@ -655,18 +735,20 @@ def count_argument_resolver(resolved_argument: ResolvedArgument) -> AttributeKey def validate_trace_metric_aggregate_arguments( arguments: list[ValueArgumentDefinition | AttributeArgumentDefinition], + offset: int = 0, + expected_args: int = 4, ) -> None: - if len(arguments) != 4: + if len(arguments) != expected_args: raise InvalidSearchQuery( - f"Trace metric aggregates expects exactly 4 arguments to be defined, got {len(arguments)}" + f"Trace metric aggregates expects exactly {expected_args} arguments to be defined, got {len(arguments)}" ) - if not isinstance(arguments[0], AttributeArgumentDefinition): + if not isinstance(arguments[offset], AttributeArgumentDefinition): raise InvalidSearchQuery( - "Trace metric aggregates expect argument 0 to be of type AttributeArgumentDefinition" + f"Trace metric aggregates expect argument {offset} to be of type AttributeArgumentDefinition" ) - for i in range(1, 4): + for i in range(offset + 1, expected_args): if not isinstance(arguments[i], ValueArgumentDefinition): raise InvalidSearchQuery( f"Trace metric aggregates expects argument {i} to be of type ValueArgumentDefinition" @@ -675,21 +757,24 @@ def validate_trace_metric_aggregate_arguments( def extract_trace_metric_aggregate_arguments( resolved_arguments: ResolvedArguments, + offset: int = 0, ) -> TraceMetric | None: if all( isinstance(resolved_argument, str) and resolved_argument != "" - for resolved_argument in resolved_arguments[1:] + for resolved_argument in resolved_arguments[offset + 1 :] ): # a metric was passed return TraceMetric( - metric_name=cast(str, resolved_arguments[1]), - metric_type=cast(TraceMetricType, resolved_arguments[2]), - metric_unit=None if resolved_arguments[3] == "-" else cast(str, resolved_arguments[3]), + metric_name=cast(str, resolved_arguments[offset + 1]), + metric_type=cast(TraceMetricType, resolved_arguments[offset + 2]), + metric_unit=None + if resolved_arguments[offset + 3] == "-" + else cast(str, resolved_arguments[offset + 3]), ) - elif all(resolved_argument == "" for resolved_argument in resolved_arguments[1:]): + elif all(resolved_argument == "" for resolved_argument in resolved_arguments[offset + 1 :]): # no metrics were specified, assume we query all metrics return None raise InvalidSearchQuery( - f"Trace metric aggregates expect the full metric to be specified, got name:{resolved_arguments[1]} type:{resolved_arguments[2]} unit:{resolved_arguments[3]}" + f"Trace metric aggregates expect the full metric to be specified, got name:{resolved_arguments[offset + 1]} type:{resolved_arguments[offset + 2]} unit:{resolved_arguments[offset + 3]}" ) diff --git a/src/sentry/search/eap/resolver.py b/src/sentry/search/eap/resolver.py index 53822371291a..7970d9027f02 100644 --- a/src/sentry/search/eap/resolver.py +++ b/src/sentry/search/eap/resolver.py @@ -1107,7 +1107,7 @@ def resolve_function( if function_definition.private and function_name not in self.config.fields_acl.functions: raise InvalidSearchQuery(f"The function {function_name} is not allowed for this query") - parsed_args: list[ResolvedAttribute | str | int | float] = [] + parsed_args: list[ResolvedAttribute | str | int | float | TraceItemFilter] = [] # Parse the arguments arguments = fields.parse_arguments(function_name, columns) @@ -1154,6 +1154,14 @@ def resolve_function( parsed_args.append(int(argument)) elif arg_type == "number": parsed_args.append(float(argument)) + elif arg_type == "query": + # Only TraceItemFilter currently supported + trace_item_filters = self.resolve_query(argument[1:-1])[0] + if trace_item_filters is None: + raise InvalidSearchQuery( + "The if combinator requires non-aggregate filters" + ) + parsed_args.append(trace_item_filters) else: parsed_args.append(argument) continue diff --git a/src/sentry/search/eap/trace_metrics/aggregates.py b/src/sentry/search/eap/trace_metrics/aggregates.py index 3a22e3a14d38..7df48923658a 100644 --- a/src/sentry/search/eap/trace_metrics/aggregates.py +++ b/src/sentry/search/eap/trace_metrics/aggregates.py @@ -1,3 +1,5 @@ +from typing import Callable + from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, Function from sentry.search.eap import constants @@ -5,6 +7,7 @@ from sentry.search.eap.columns import ( AggregateDefinition, AttributeArgumentDefinition, + ConditionalTraceMetricAggregateDefinition, TraceMetricAggregateDefinition, ValueArgumentDefinition, count_argument_resolver_optimized, @@ -363,3 +366,41 @@ ], ), } + + +def if_query_validator(term: str) -> bool: + if not term.startswith("`") or not term.endswith("`"): + return False + return True + + +def if_combinator(definition: AggregateDefinition) -> AggregateDefinition: + # Copy the TraceMetricAggregateDefinition but make it Conditional + if_definition = ConditionalTraceMetricAggregateDefinition( + attribute_resolver=definition.attribute_resolver, + default_search_type=definition.default_search_type, + extrapolation_mode_override=definition.extrapolation_mode_override, + infer_search_type_from_arguments=definition.infer_search_type_from_arguments, + internal_function=definition.internal_function, + internal_type=definition.internal_type, + processor=definition.processor, + private=definition.private, + arguments=[ + ValueArgumentDefinition(argument_types={"query"}, validator=if_query_validator), + *definition.arguments, + ], + ) + return if_definition + + +TRACE_METRICS_COMBINATORS: dict[str, Callable[[AggregateDefinition], AggregateDefinition]] = { + "if": if_combinator, +} + + +combinator_aggregate_definitions: dict[str, AggregateDefinition] = {} +for combinator, apply_combinator in TRACE_METRICS_COMBINATORS.items(): + for function, definition in TRACE_METRICS_AGGREGATE_DEFINITIONS.items(): + combinator_aggregate_definitions[f"{function}_{combinator}"] = apply_combinator(definition) + +TRACE_METRICS_AGGREGATE_DEFINITIONS.update(combinator_aggregate_definitions) diff --git a/src/sentry/search/events/fields.py b/src/sentry/search/events/fields.py index 94f06647734b..2fe6427e94b7 100644 --- a/src/sentry/search/events/fields.py +++ b/src/sentry/search/events/fields.py @@ -484,18 +484,23 @@ def parse_arguments(_function: str, columns: str) -> list[str]: quoted = False in_tag = False escaped = False + in_filter = False i, j = 0, 0 while j < len(columns): - if not in_tag and i == j and columns[j] == '"': + if not in_filter and not in_tag and i == j and columns[j] == '"': # when we see a quote at the beginning of # an argument, then this is a quoted string quoted = True - elif not quoted and columns[j] == "[" and _lookback(columns, j, "tags"): + elif not in_filter and not quoted and columns[j] == "[" and _lookback(columns, j, "tags"): # when the argument begins with tags[, # then this is the beginning of the tag that may contain commas in_tag = True + elif not quoted and i == j and columns[j] == "`": + # When the argument starts with ` + # then its a filter that may contain any number of characters + in_filter = True elif i == j and columns[j] == " ": # argument has leading spaces, skip over them i += 1 @@ -511,6 +516,9 @@ def parse_arguments(_function: str, columns: str) -> list[str]: # when we see a non-escaped quote while inside # of a quoted string, we should end it in_tag = False + elif in_filter and columns[j] == "`": + # When we see a ` while we're in a filter end it + in_filter = False elif quoted and escaped: # when we are inside a quoted string and have # begun an escape character, we should end it @@ -520,7 +528,7 @@ def parse_arguments(_function: str, columns: str) -> list[str]: # a comma, it should not be considered an # argument separator pass - elif columns[j] == ",": + elif not in_filter and columns[j] == ",": # when we see a comma outside of a quoted string # it is an argument separator args.append(columns[i:j].strip()) diff --git a/src/sentry/seer/autofix/coding_agent.py b/src/sentry/seer/autofix/coding_agent.py index ad9e9fd3cca7..5d61bdb065e3 100644 --- a/src/sentry/seer/autofix/coding_agent.py +++ b/src/sentry/seer/autofix/coding_agent.py @@ -12,6 +12,24 @@ from rest_framework.exceptions import APIException, NotFound, PermissionDenied, ValidationError from sentry import features + + +class IntegrationNotFound(NotFound): + pass + + +class OrganizationNotFound(NotFound): + pass + + +class AutofixStateNotFound(NotFound): + pass + + +class StateReposNotFound(NotFound): + pass + + from sentry.constants import ENABLE_SEER_CODING_DEFAULT, ObjectStatus from sentry.integrations.claude_code.integration import ( ClaudeCodeIntegrationMetadata, @@ -118,7 +136,7 @@ def _validate_and_get_integration(organization, integration_id: int): ) if not org_integration or org_integration.status != ObjectStatus.ACTIVE: - raise NotFound("Integration not found") + raise IntegrationNotFound("Integration not found") integration = integration_service.get_integration( organization_integration_id=org_integration.id, @@ -126,7 +144,7 @@ def _validate_and_get_integration(organization, integration_id: int): ) if not integration: - raise NotFound("Integration not found") + raise IntegrationNotFound("Integration not found") # Verify it's a coding agent integration if integration.provider not in get_coding_agent_providers(): @@ -252,7 +270,7 @@ def _launch_agents_for_repos( repos_to_launch = validated_repos or autofix_state_repos if not repos_to_launch: - raise NotFound( + raise StateReposNotFound( "There are no repos in the Seer state to launch coding agents with, make sure you have repos connected to Seer and rerun this Issue Fix." ) @@ -426,7 +444,7 @@ def launch_coding_agents_for_run( try: organization = Organization.objects.get(id=organization_id) except Organization.DoesNotExist: - raise NotFound("Organization not found") + raise OrganizationNotFound("Organization not found") if not organization.get_option("sentry:enable_seer_coding", default=ENABLE_SEER_CODING_DEFAULT): raise PermissionDenied("Code generation is disabled for this organization") @@ -457,7 +475,7 @@ def launch_coding_agents_for_run( autofix_state = _get_autofix_state(run_id, organization) if autofix_state is None: - raise NotFound("Autofix state not found") + raise AutofixStateNotFound("Autofix state not found") logger.info( "coding_agent.launch_request", diff --git a/src/sentry/seer/autofix/on_completion_hook.py b/src/sentry/seer/autofix/on_completion_hook.py index 84b6df842f4e..0f86910bb207 100644 --- a/src/sentry/seer/autofix/on_completion_hook.py +++ b/src/sentry/seer/autofix/on_completion_hook.py @@ -8,14 +8,22 @@ from sentry import features from sentry.models.group import Group from sentry.models.organization import Organization +from sentry.models.project import Project from sentry.seer.autofix.autofix_agent import ( AutofixStep, trigger_autofix_explorer, trigger_coding_agent_handoff, trigger_push_changes, ) +from sentry.seer.autofix.coding_agent import IntegrationNotFound from sentry.seer.autofix.constants import AutofixReferrer -from sentry.seer.autofix.utils import AutofixStoppingPoint, get_project_seer_preferences +from sentry.seer.autofix.utils import ( + AutofixStoppingPoint, + get_project_seer_preferences, + resolve_repository_ids, + set_project_seer_preference, + write_preference_to_sentry_db, +) from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates from sentry.seer.explorer.client_models import Artifact from sentry.seer.explorer.client_utils import fetch_run_status @@ -25,6 +33,7 @@ SeerApiResponseValidationError, SeerAutomationHandoffConfiguration, ) +from sentry.seer.models.seer_api_models import SeerProjectPreference from sentry.seer.supergroups.embeddings import trigger_supergroups_embedding from sentry.sentry_apps.metrics import SentryAppEventType from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization @@ -472,6 +481,35 @@ def _get_handoff_config_if_applicable( return handoff_config + @classmethod + def _clear_handoff_preference( + cls, project: Project, run_id: int, organization: Organization + ) -> None: + """Clear automation_handoff from project preferences after integration is not found.""" + try: + preference_response = get_project_seer_preferences(project.id) + if preference_response and preference_response.preference: + updated_preference = preference_response.preference.copy( + update={"automation_handoff": None} + ) + set_project_seer_preference(updated_preference) + + if features.has("organizations:seer-project-settings-dual-write", organization): + try: + validated_pref = SeerProjectPreference.validate(updated_preference) + resolved_pref = resolve_repository_ids(organization.id, [validated_pref]) + write_preference_to_sentry_db(project, resolved_pref[0]) + except Exception: + logger.exception( + "seer.write_preferences.failed", + extra={"project_id": project.id, "organization_id": organization.id}, + ) + except (SeerApiError, SeerApiResponseValidationError): + logger.exception( + "autofix.on_completion_hook.clear_handoff_preference_failed", + extra={"run_id": run_id, "organization_id": organization.id}, + ) + @classmethod def _trigger_coding_agent_handoff( cls, @@ -508,6 +546,16 @@ def _trigger_coding_agent_handoff( "failures": len(result.get("failures", [])), }, ) + except IntegrationNotFound: + logger.exception( + "autofix.on_completion_hook.coding_agent_handoff_integration_not_found", + extra={ + "run_id": run_id, + "organization_id": organization.id, + "integration_id": handoff_config.integration_id, + }, + ) + cls._clear_handoff_preference(group.project, run_id, organization) except Exception: logger.exception( "autofix.on_completion_hook.coding_agent_handoff_failed", diff --git a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py index dd025bf950b1..70991078d393 100644 --- a/src/sentry/seer/endpoints/organization_autofix_automation_settings.py +++ b/src/sentry/seer/endpoints/organization_autofix_automation_settings.py @@ -304,7 +304,7 @@ def post(self, request: Request, organization: Organization) -> Response: continue project_id_str = str(proj_id) - existing_pref = existing_preferences.get(project_id_str, {}) + existing_pref = existing_preferences.get(project_id_str) or {} pref_update: dict[str, Any] = { **default_seer_project_preference(project).dict(), diff --git a/src/sentry/seer/endpoints/seer_rpc.py b/src/sentry/seer/endpoints/seer_rpc.py index f6c034d673e2..cdabc2867d0b 100644 --- a/src/sentry/seer/endpoints/seer_rpc.py +++ b/src/sentry/seer/endpoints/seer_rpc.py @@ -36,6 +36,7 @@ from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue, StrArray from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter, TraceItemFilter +from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.authentication import AuthenticationSiloLimit, StandardAuthentication @@ -73,8 +74,19 @@ get_attribute_values_with_substring, ) from sentry.seer.autofix.autofix_tools import get_error_event_details, get_profile_details -from sentry.seer.autofix.coding_agent import launch_coding_agents_for_run -from sentry.seer.autofix.utils import AutofixTriggerSource +from sentry.seer.autofix.coding_agent import ( + AutofixStateNotFound, + IntegrationNotFound, + OrganizationNotFound, + StateReposNotFound, + launch_coding_agents_for_run, +) +from sentry.seer.autofix.utils import ( + AutofixTriggerSource, + get_project_seer_preferences, + resolve_repository_ids, + write_preference_to_sentry_db, +) from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS, SeerSCMProvider from sentry.seer.entrypoints.operator import SeerAutofixOperator, process_autofix_updates from sentry.seer.explorer.custom_tool_utils import call_custom_tool @@ -104,6 +116,7 @@ ) from sentry.seer.fetch_issues import by_error_type, by_function_name, by_text_query, utils from sentry.seer.issue_detection import create_issue_occurrence +from sentry.seer.models.seer_api_models import SeerProjectPreference from sentry.seer.utils import filter_repo_by_provider from sentry.sentry_apps.metrics import SentryAppEventType from sentry.sentry_apps.tasks.sentry_apps import broadcast_webhooks_for_organization @@ -565,6 +578,7 @@ def send_seer_webhook(*, event_name: str, organization_id: int, payload: dict) - def trigger_coding_agent_launch( *, organization_id: int, + project_id: int | None = None, integration_id: int, run_id: int, trigger_source: str = "solution", @@ -579,7 +593,7 @@ def trigger_coding_agent_launch( trigger_source: Either "root_cause" or "solution" (default: "solution") Returns: - dict: {"success": bool} + dict: {"success": bool, "error_code": str | None} """ try: launch_coding_agents_for_run( @@ -589,7 +603,45 @@ def trigger_coding_agent_launch( trigger_source=AutofixTriggerSource(trigger_source), ) return {"success": True} - except (NotFound, PermissionDenied, ValidationError, APIException): + except IntegrationNotFound: + logger.exception( + "coding_agent.rpc_launch_error", + extra={ + "organization_id": organization_id, + "integration_id": integration_id, + "run_id": run_id, + }, + ) + try: + project = Project.objects.get_from_cache(id=project_id) + organization = Organization.objects.get_from_cache(id=organization_id) + if features.has("organizations:seer-project-settings-dual-write", organization): + preference_response = get_project_seer_preferences(project.id) + if preference_response and preference_response.preference: + updated_preference = preference_response.preference.copy( + update={"automation_handoff": None} + ) + validated_pref = SeerProjectPreference.validate(updated_preference) + resolved_pref = resolve_repository_ids(organization.id, [validated_pref]) + write_preference_to_sentry_db(project, resolved_pref[0]) + except Exception: + logger.exception( + "coding_agent.clear_handoff_preference_failed", + extra={ + "project_id": project_id, + "organization_id": organization_id, + "run_id": run_id, + }, + ) + return {"success": False, "error_code": "integration_not_found"} + except ( + OrganizationNotFound, + AutofixStateNotFound, + StateReposNotFound, + PermissionDenied, + ValidationError, + APIException, + ): logger.exception( "coding_agent.rpc_launch_error", extra={ diff --git a/src/sentry/sentry_apps/api/endpoints/sentry_app_rotate_secret.py b/src/sentry/sentry_apps/api/endpoints/sentry_app_rotate_secret.py index fc951688ffe5..af4da3b4eda3 100644 --- a/src/sentry/sentry_apps/api/endpoints/sentry_app_rotate_secret.py +++ b/src/sentry/sentry_apps/api/endpoints/sentry_app_rotate_secret.py @@ -77,7 +77,7 @@ class SentryAppRotateSecretEndpoint(SentryAppBaseEndpoint): publish_status = { "POST": ApiPublishStatus.PRIVATE, } - owner = ApiOwner.ENTERPRISE + owner = ApiOwner.ECOSYSTEM permission_classes = (SentryAppRotateSecretPermission,) def post(self, request: Request, sentry_app: SentryApp) -> Response: diff --git a/src/sentry/snuba/discover.py b/src/sentry/snuba/discover.py index 7c026f16f6e7..8efa6fa8885f 100644 --- a/src/sentry/snuba/discover.py +++ b/src/sentry/snuba/discover.py @@ -438,10 +438,8 @@ def create_groupby_dict( value = f"[{','.join(filtered_value)}]" else: value = "" - if stringify_none: - values.append(GroupBy(key=field, value=str(value))) - else: - values.append(GroupBy(key=field, value=str(value) if value is not None else None)) + none_value = "None" if stringify_none else None + values.append(GroupBy(key=field, value=value if value is not None else none_value)) return values diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py index 8996e68a9748..06892fc7c490 100644 --- a/src/sentry/snuba/referrer.py +++ b/src/sentry/snuba/referrer.py @@ -675,6 +675,7 @@ class Referrer(StrEnum): INSIGHTS_TIME_SPENT_TOTAL_TIME = "insights.time_spent.total_time" METRIC_EXTRACTION_CARDINALITY_CHECK = "metric_extraction.cardinality_check" + BILLING_USAGE_SERVICE_CLICKHOUSE = "billing.usage_service.clickhouse" OUTCOMES_TIMESERIES = "outcomes.timeseries" OUTCOMES_TOTALS = "outcomes.totals" PREVIEW_GET_EVENTS = "preview.get_events" diff --git a/src/sentry/tasks/console_platform_cleanup.py b/src/sentry/tasks/console_platform_cleanup.py new file mode 100644 index 000000000000..67bbbf8b460d --- /dev/null +++ b/src/sentry/tasks/console_platform_cleanup.py @@ -0,0 +1,64 @@ +import logging +from typing import cast + +from django.conf import settings + +from sentry.models.options.project_option import ProjectOption +from sentry.silo.base import SiloMode +from sentry.tasks.base import instrumented_task +from sentry.taskworker.namespaces import symbolication_tasks + +logger = logging.getLogger(__name__) + + +@instrumented_task( + name="sentry.tasks.console_platform_cleanup.remove_inaccessible_console_platform_sources", + namespace=symbolication_tasks, + silo_mode=SiloMode.CELL, +) +def remove_inaccessible_console_platform_sources( + organization_id: int, current_platforms: list[str], **kwargs +) -> None: + """ + Remove symbol sources that the organization can no longer access from all + projects in the organization. + + Called when console platform access is revoked. ``current_platforms`` is the + set of console platforms the organization still has access to *after* the + revocation. Any builtin source whose required platforms are all absent from + this list is removed from every project's ``sentry:builtin_symbol_sources``. + """ + # A source is accessible when the org has access to *any* of its platforms + # (mirroring BuiltinSymbolSourcesEndpoint), so remove it when the org has + # access to *none* of them. + current_platform_set = set(current_platforms) + source_keys_to_remove: set[str] = set() + for key, source in settings.SENTRY_BUILTIN_SOURCES.items(): + source_platforms: list[str] | None = cast("list[str] | None", source.get("platforms")) + if source_platforms is None: + continue + if not current_platform_set.intersection(source_platforms): + source_keys_to_remove.add(key) + + if not source_keys_to_remove: + return + + project_options = ProjectOption.objects.filter( + project__organization_id=organization_id, + key="sentry:builtin_symbol_sources", + ) + + for option in project_options: + current_sources: list[str] = option.value or [] + updated_sources = [s for s in current_sources if s not in source_keys_to_remove] + + if updated_sources != current_sources: + ProjectOption.objects.set_value(option.project_id, option.key, updated_sources) + logger.info( + "console_platform_cleanup.removed_sources", + extra={ + "organization_id": organization_id, + "project_id": option.project_id, + "removed_sources": list(source_keys_to_remove & set(current_sources)), + }, + ) diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index aff0cddbce29..cb3cd02f25d4 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -379,10 +379,6 @@ class Factories: @staticmethod @assume_test_silo_mode(SiloMode.CELL) def create_organization(name=None, owner=None, cell: Cell | str | None = None, **kwargs): - # TODO(cells): Remove once getsentry passes cell everywhere - if not cell: - cell = kwargs.pop("region", None) - if not name: name = petname.generate(2, " ", letters=10).title() diff --git a/src/sentry/testutils/pytest/relay.py b/src/sentry/testutils/pytest/relay.py index 3fb2fbff6f91..3729e5ae5765 100644 --- a/src/sentry/testutils/pytest/relay.py +++ b/src/sentry/testutils/pytest/relay.py @@ -69,8 +69,7 @@ def relay_server_setup(live_server, tmpdir_factory): template_path = _get_template_dir() sources = ["config.yml", "credentials.json"] - worker_num = xdist._worker_num if xdist._worker_num is not None else 0 - relay_port = ephemeral_port_reserve.reserve(ip="127.0.0.1", port=33331 + worker_num * 100) + relay_port = ephemeral_port_reserve.reserve(ip="127.0.0.1", port=0) redis_db = xdist.get_redis_db() diff --git a/src/sentry/utils/cursored_scheduler.py b/src/sentry/utils/cursored_scheduler.py new file mode 100644 index 000000000000..fbe04ad8aa7d --- /dev/null +++ b/src/sentry/utils/cursored_scheduler.py @@ -0,0 +1,252 @@ +""" +A general-purpose framework for processing large querysets in batches, +spread over time via a scheduled task. + +The framework takes a queryset, a task, a cycle duration, and a schedule key. +It reads the tick interval from the schedule config, then automatically +calculates the batch size needed to complete one full cycle within the target +duration. Each invocation, it fetches the next batch of PKs and dispatches +the task for each one. The cursor is stored in cache (Redis) so progress +persists across invocations. A distributed lock prevents overlapping ticks. + +Usage: + + from datetime import timedelta + from sentry.utils.cursored_scheduler import CursoredScheduler + + # my_module/tasks.py + scheduler = CursoredScheduler( + name="my_sync", + schedule_key="my-sync-beat", + queryset=MyModel.objects.filter(status=ACTIVE), + task=process_item, + cycle_duration=timedelta(hours=6), + ) + + @instrumented_task(name="sentry.my_module.tasks.my_sync_beat", ...) + def my_sync_beat(): + scheduler.tick() + + # server.py — TASKWORKER_SCHEDULES + "my-sync-beat": { + "task": "namespace:sentry.my_module.tasks.my_sync_beat", + "schedule": timedelta(minutes=1), # must be timedelta, not crontab + }, + +The task will be called with the PK as a positional argument: + process_item.delay(item_pk) +""" + +from __future__ import annotations + +import logging +import math +import time +from datetime import timedelta + +from django.conf import settings +from django.core.cache import cache +from django.db.models import Model, QuerySet +from taskbroker_client.task import Task + +from sentry.locks import locks +from sentry.utils import metrics +from sentry.utils.locking import UnableToAcquireLock + +logger = logging.getLogger(__name__) + +CURSOR_CACHE_KEY_PREFIX = "cursored_scheduler" +BATCH_SIZE_CACHE_KEY_PREFIX = "cursored_scheduler_batch_size" +CYCLE_START_CACHE_KEY_PREFIX = "cursored_scheduler_cycle_start" +LOCK_PREFIX = "cursored_scheduler_lock" +# How long to hold the lock during a tick +DEFAULT_LOCK_DURATION_SECONDS = 120 +# Minimum batch size +MIN_BATCH_SIZE = 1 + + +def _get_tick_interval(schedule_key: str) -> timedelta: + """ + Read the tick interval from TASKWORKER_SCHEDULES for the given key. + Requires the schedule to be a timedelta, not a crontab. + """ + schedules = getattr(settings, "TASKWORKER_SCHEDULES", {}) + if schedule_key not in schedules: + raise ValueError( + f"Schedule key '{schedule_key}' not found in TASKWORKER_SCHEDULES. " + f"Register it in server.py before creating a CursoredScheduler." + ) + + schedule = schedules[schedule_key].get("schedule") + if not isinstance(schedule, timedelta): + raise TypeError( + f"CursoredScheduler requires a timedelta schedule, " + f"but '{schedule_key}' uses {type(schedule).__name__}. " + f"Change the schedule in server.py to use timedelta instead of crontab." + ) + + return schedule + + +class CursoredScheduler[M: Model]: + """ + Processes a queryset in batches over multiple scheduled task invocations. + + Each call to tick() acquires a lock, fetches the next batch of rows by PK, + dispatches the configured task for each row's PK, and advances the cursor. + When all rows have been processed, the cursor resets and a new cycle + begins on the next tick(). + + Batch size is auto-calculated at the start of each cycle based on the total + row count, cycle_duration, and the tick interval from the schedule config, + so that one full pass through the queryset completes within approximately + cycle_duration. + """ + + def __init__( + self, + name: str, + schedule_key: str, + queryset: QuerySet[M], + task: Task[[int], None], + cycle_duration: timedelta, + lock_duration: int = DEFAULT_LOCK_DURATION_SECONDS, + ): + self.name = name + self.schedule_key = schedule_key + self.cache_key = f"{CURSOR_CACHE_KEY_PREFIX}:{name}" + self.batch_size_cache_key = f"{BATCH_SIZE_CACHE_KEY_PREFIX}:{name}" + self.cycle_start_cache_key = f"{CYCLE_START_CACHE_KEY_PREFIX}:{name}" + self.lock_key = f"{LOCK_PREFIX}:{name}" + self.queryset = queryset + self.task = task + self.cycle_duration = cycle_duration + self.cache_ttl = int(cycle_duration.total_seconds() * 2) + self.lock_duration = lock_duration + self._metric_tags = {"scheduler": name} + + @property + def tick_interval(self) -> timedelta: + return _get_tick_interval(self.schedule_key) + + def tick(self) -> bool: + """ + Process the next batch. Call this from your beat task. + + Acquires a lock to prevent overlapping ticks, fetches the next batch + of PKs from the queryset, dispatches the task for each one, and + advances the cursor. + + Returns True if there are more items to process, False if the cycle + is complete. Returns False without processing if the lock cannot be + acquired. + """ + lock = locks.get( + key=self.lock_key, + duration=self.lock_duration, + name=self.name, + ) + + try: + with lock.acquire(): + return self._process_batch() + except UnableToAcquireLock: + metrics.incr("cursored_scheduler.lock_contention", tags=self._metric_tags) + logger.info( + "cursored_scheduler.lock_contention", + extra={"scheduler": self.name}, + ) + return False + + def _process_batch(self) -> bool: + tick_start = time.time() + + cursor = self._get_cursor() + queryset = self.queryset + + if cursor == 0: + batch_size = self._initialize_batch(queryset) + else: + batch_size = self._get_batch_size() + + items = list( + queryset.filter(pk__gt=cursor).order_by("pk").values_list("pk", flat=True)[:batch_size] + ) + + if not items: + self._finalize_batch() + metrics.timing( + "cursored_scheduler.tick_duration", time.time() - tick_start, tags=self._metric_tags + ) + return False + + for pk in items: + self.task.delay(pk) + + dispatched = len(items) + metrics.gauge("cursored_scheduler.batch_size", dispatched, tags=self._metric_tags) + + self._set_cursor(items[-1]) + + metrics.timing( + "cursored_scheduler.tick_duration", time.time() - tick_start, tags=self._metric_tags + ) + + if dispatched < batch_size: + self._finalize_batch() + return False + + return True + + def _initialize_batch(self, queryset: QuerySet[M]) -> int: + batch_size = self._calculate_batch_size(queryset) + cache.set(self.batch_size_cache_key, batch_size, self.cache_ttl) + cache.set(self.cycle_start_cache_key, time.time(), self.cache_ttl) + return batch_size + + def _finalize_batch(self): + """Reset cursor and batch size to 0, starting a new cycle on the next tick.""" + self._emit_cycle_duration() + cache.set(self.cache_key, 0, self.cache_ttl) + cache.set(self.batch_size_cache_key, 0, self.cache_ttl) + cache.delete(self.cycle_start_cache_key) + metrics.incr("cursored_scheduler.cycle_complete", tags=self._metric_tags) + + def _calculate_batch_size(self, queryset: QuerySet[M]) -> int: + """ + Calculate batch size based on total rows, cycle duration, and tick interval. + + batch_size = ceil(total_rows / ticks_per_cycle) + """ + total_rows = queryset.count() + ticks_per_cycle = self.cycle_duration / self.tick_interval + batch_size = math.ceil(total_rows / ticks_per_cycle) + return max(batch_size, MIN_BATCH_SIZE) + + def _emit_cycle_duration(self) -> None: + """Emit timing metrics for the completed cycle.""" + cycle_start = cache.get(self.cycle_start_cache_key) + if cycle_start is None: + return + + elapsed_seconds = time.time() - float(cycle_start) + target_seconds = self.cycle_duration.total_seconds() + drift_seconds = elapsed_seconds - target_seconds + + metrics.timing("cursored_scheduler.cycle_duration", elapsed_seconds, tags=self._metric_tags) + metrics.timing("cursored_scheduler.cycle_drift", drift_seconds, tags=self._metric_tags) + + def _get_cursor(self) -> int: + value = cache.get(self.cache_key) + if value is None: + return 0 + return int(value) + + def _set_cursor(self, cursor: int) -> None: + cache.set(self.cache_key, cursor, self.cache_ttl) + + def _get_batch_size(self) -> int: + value = cache.get(self.batch_size_cache_key) + if value is None: + return 0 + return int(value) diff --git a/src/sentry/workflow_engine/endpoints/organization_detector_details.py b/src/sentry/workflow_engine/endpoints/organization_detector_details.py index b4bf4556ae02..a399c2238980 100644 --- a/src/sentry/workflow_engine/endpoints/organization_detector_details.py +++ b/src/sentry/workflow_engine/endpoints/organization_detector_details.py @@ -1,6 +1,5 @@ from typing import Any -from django.db import router, transaction from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.exceptions import PermissionDenied, ValidationError @@ -23,7 +22,6 @@ ) from sentry.apidocs.examples.workflow_engine_examples import WorkflowEngineExamples from sentry.apidocs.parameters import DetectorParams, GlobalParams -from sentry.db.postgres.transactions import in_test_hide_transaction_boundary from sentry.incidents.grouptype import MetricIssue from sentry.incidents.metric_issue_detector import schedule_update_project_config from sentry.issues import grouptype @@ -33,9 +31,6 @@ from sentry.workflow_engine.endpoints.serializers.detector_serializer import DetectorSerializer from sentry.workflow_engine.endpoints.utils.ids import to_valid_int_id from sentry.workflow_engine.endpoints.validators.base import BaseDetectorTypeValidator -from sentry.workflow_engine.endpoints.validators.detector_workflow import ( - BulkDetectorWorkflowsValidator, -) from sentry.workflow_engine.endpoints.validators.utils import ( can_delete_detector, can_edit_detector, @@ -193,26 +188,7 @@ def put(self, request: Request, organization: Organization, detector: Detector) if not validator.is_valid(): return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) - with transaction.atomic(router.db_for_write(Detector)): - with in_test_hide_transaction_boundary(): - updated_detector = validator.save() - - workflow_ids = request.data.get("workflowIds") - if workflow_ids is not None: - bulk_validator = BulkDetectorWorkflowsValidator( - data={ - "detector_id": detector.id, - "workflow_ids": workflow_ids, - }, - context={ - "organization": organization, - "request": request, - }, - ) - if not bulk_validator.is_valid(): - raise ValidationError({"workflowIds": bulk_validator.errors}) - - bulk_validator.save() + updated_detector = validator.save() return Response(serialize(updated_detector, request.user), status=status.HTTP_200_OK) diff --git a/src/sentry/workflow_engine/endpoints/organization_detector_index.py b/src/sentry/workflow_engine/endpoints/organization_detector_index.py index d236ef4645cb..b6c535ad9e30 100644 --- a/src/sentry/workflow_engine/endpoints/organization_detector_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_detector_index.py @@ -55,9 +55,6 @@ from sentry.workflow_engine.endpoints.utils.filters import apply_filter from sentry.workflow_engine.endpoints.utils.ids import to_valid_int_id, to_valid_int_id_list from sentry.workflow_engine.endpoints.validators.base import BaseDetectorTypeValidator -from sentry.workflow_engine.endpoints.validators.detector_workflow import ( - BulkDetectorWorkflowsValidator, -) from sentry.workflow_engine.endpoints.validators.detector_workflow_mutation import ( DetectorWorkflowMutationValidator, ) @@ -353,26 +350,7 @@ def post(self, request: Request, organization: Organization) -> Response: if not validator.is_valid(): return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) - with transaction.atomic(router.db_for_write(Detector)): - detector = validator.save() - - # Handle workflow connections in bulk - workflow_ids = request.data.get("workflowIds", []) - if workflow_ids: - bulk_validator = BulkDetectorWorkflowsValidator( - data={ - "detector_id": detector.id, - "workflow_ids": workflow_ids, - }, - context={ - "organization": organization, - "request": request, - }, - ) - if not bulk_validator.is_valid(): - raise ValidationError({"workflowIds": bulk_validator.errors}) - - bulk_validator.save() + detector = validator.save() return Response(serialize(detector, request.user), status=status.HTTP_201_CREATED) diff --git a/src/sentry/workflow_engine/endpoints/organization_project_detector_index.py b/src/sentry/workflow_engine/endpoints/organization_project_detector_index.py index c34885991f56..5e912d24553a 100644 --- a/src/sentry/workflow_engine/endpoints/organization_project_detector_index.py +++ b/src/sentry/workflow_engine/endpoints/organization_project_detector_index.py @@ -1,4 +1,3 @@ -from django.db import router, transaction from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.exceptions import ValidationError @@ -24,10 +23,6 @@ from sentry.workflow_engine.endpoints.organization_detector_index import get_detector_validator from sentry.workflow_engine.endpoints.serializers.detector_serializer import DetectorSerializer from sentry.workflow_engine.endpoints.validators.base import BaseDetectorTypeValidator -from sentry.workflow_engine.endpoints.validators.detector_workflow import ( - BulkDetectorWorkflowsValidator, -) -from sentry.workflow_engine.models import Detector class OrganizationProjectDetectorPermission(ProjectPermission): @@ -82,25 +77,6 @@ def post(self, request: Request, project: Project) -> Response: if not validator.is_valid(): return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST) - with transaction.atomic(router.db_for_write(Detector)): - detector = validator.save() - - # Handle workflow connections in bulk - workflow_ids = request.data.get("workflowIds", []) - if workflow_ids: - bulk_validator = BulkDetectorWorkflowsValidator( - data={ - "detector_id": detector.id, - "workflow_ids": workflow_ids, - }, - context={ - "organization": organization, - "request": request, - }, - ) - if not bulk_validator.is_valid(): - raise ValidationError({"workflowIds": bulk_validator.errors}) - - bulk_validator.save() + detector = validator.save() return Response(serialize(detector, request.user), status=status.HTTP_201_CREATED) diff --git a/src/sentry/workflow_engine/endpoints/validators/base/detector.py b/src/sentry/workflow_engine/endpoints/validators/base/detector.py index 088a8abf6770..935ca59f61b2 100644 --- a/src/sentry/workflow_engine/endpoints/validators/base/detector.py +++ b/src/sentry/workflow_engine/endpoints/validators/base/detector.py @@ -25,6 +25,7 @@ BaseDataConditionValidator, ) from sentry.workflow_engine.endpoints.validators.utils import ( + connect_detectors_to_workflows, get_unknown_detector_type_error, log_alerting_quota_hit, toggle_detector, @@ -66,6 +67,11 @@ class BaseDetectorTypeValidator(CamelSnakeSerializer[Any]): help_text="Name of the monitor.", ) type = serializers.CharField(help_text="The type of monitor - `metric_issue`.") + workflow_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text="The IDs of the alerts to connect this monitor to. Use the 'Fetch Alerts' endpoint to find the IDs.", + ) data_sources = serializers.ListField( required=False, help_text=DATA_SOURCES_HELP_TEXT, @@ -178,6 +184,9 @@ def enforce_quota(self, validated_data: dict[str, Any]) -> None: ) def update(self, instance: Detector, validated_data: dict[str, Any]) -> Detector: + organization = self.context["organization"] + request = self.context["request"] + with transaction.atomic(router.db_for_write(Detector)): if "name" in validated_data: instance.name = validated_data.get("name", instance.name) @@ -215,15 +224,27 @@ def update(self, instance: Detector, validated_data: dict[str, Any]) -> Detector except JSONSchemaValidationError as error: raise serializers.ValidationError({"config": [str(error)]}) + # Update detector connections + workflow_ids = None + if "workflow_ids" in validated_data: + workflow_ids = validated_data.pop("workflow_ids") + connect_detectors_to_workflows( + request, + organization, + instance.id, + workflow_ids, + update=True, + ) + instance.save() - create_audit_entry( - request=self.context["request"], - organization=self.context["organization"], - target_object=instance.id, - event=audit_log.get_event_id("DETECTOR_EDIT"), - data=instance.get_audit_log_data(), - ) + create_audit_entry( + request=request, + organization=organization, + target_object=instance.id, + event=audit_log.get_event_id("DETECTOR_EDIT"), + data=instance.get_audit_log_data(), + ) return instance @@ -257,10 +278,13 @@ def create(self, validated_data: dict[str, Any]) -> Detector: # Do not disable or prevent the users from updating existing detectors. self.enforce_quota(validated_data) + organization = self.context["organization"] + request = self.context["request"] + with transaction.atomic(router.db_for_write(Detector)): condition_group = DataConditionGroup.objects.create( logic_type=DataConditionGroup.Type.ANY, - organization_id=self.context["organization"].id, + organization_id=organization.id, ) if "condition_group" in validated_data: @@ -283,7 +307,7 @@ def create(self, validated_data: dict[str, Any]) -> Detector: config=validated_data.get("config", {}), owner_user_id=owner_user_id, owner_team_id=owner_team_id, - created_by_id=self.context["request"].user.id, + created_by_id=request.user.id, ) try: @@ -298,9 +322,13 @@ def create(self, validated_data: dict[str, Any]) -> Detector: for validated_data_source in validated_data["data_sources"]: self._create_data_source(validated_data_source, detector) + # connect workflows + workflow_ids = validated_data.get("workflow_ids") + connect_detectors_to_workflows(request, organization, detector.id, workflow_ids) + create_audit_entry( - request=self.context["request"], - organization=self.context["organization"], + request=request, + organization=organization, target_object=detector.id, event=audit_log.get_event_id("DETECTOR_ADD"), data=detector.get_audit_log_data(), diff --git a/src/sentry/workflow_engine/endpoints/validators/detector_workflow.py b/src/sentry/workflow_engine/endpoints/validators/detector_workflow.py deleted file mode 100644 index d7b07598ed2d..000000000000 --- a/src/sentry/workflow_engine/endpoints/validators/detector_workflow.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Any, Literal - -from rest_framework import serializers - -from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer -from sentry.workflow_engine.endpoints.validators.utils import ( - perform_bulk_detector_workflow_operations, - validate_detectors_exist_and_have_permissions, - validate_workflows_exist, -) -from sentry.workflow_engine.models.detector_workflow import DetectorWorkflow - - -class BulkDetectorWorkflowsValidator(CamelSnakeSerializer[Any]): - """ - Connect/disconnect multiple workflows to a single detector all at once. - """ - - detector_id = serializers.IntegerField(required=True) - workflow_ids = serializers.ListField(child=serializers.IntegerField(), required=True) - - def create(self, validated_data: dict[str, Any]) -> list[DetectorWorkflow]: - validate_workflows_exist(validated_data["workflow_ids"], self.context["organization"]) - validate_detectors_exist_and_have_permissions( - [validated_data["detector_id"]], self.context["organization"], self.context["request"] - ) - - existing_detector_workflows = list( - DetectorWorkflow.objects.filter( - detector_id=validated_data["detector_id"], - ) - ) - new_workflow_ids = set(validated_data["workflow_ids"]) - { - dw.workflow_id for dw in existing_detector_workflows - } - - detector_workflows_to_add: list[dict[Literal["detector_id", "workflow_id"], int]] = [ - {"detector_id": validated_data["detector_id"], "workflow_id": workflow_id} - for workflow_id in new_workflow_ids - ] - detector_workflows_to_remove = [ - dw - for dw in existing_detector_workflows - if dw.workflow_id not in validated_data["workflow_ids"] - ] - - perform_bulk_detector_workflow_operations( - detector_workflows_to_add, - detector_workflows_to_remove, - self.context["request"], - self.context["organization"], - ) - - return list(DetectorWorkflow.objects.filter(detector_id=validated_data["detector_id"])) diff --git a/src/sentry/workflow_engine/endpoints/validators/utils.py b/src/sentry/workflow_engine/endpoints/validators/utils.py index 7ed8d70de84e..1f38fbdd0559 100644 --- a/src/sentry/workflow_engine/endpoints/validators/utils.py +++ b/src/sentry/workflow_engine/endpoints/validators/utils.py @@ -201,6 +201,53 @@ def get_detector_workflows_to_add( ) +def connect_detectors_to_workflows( + request: Request, + organization: Organization, + detector_id: int, + workflow_ids: list[int] | None, + update: bool = False, +) -> None: + if workflow_ids is not None: + validate_workflows_exist(workflow_ids, organization) + + def get_detector_workflows_to_add( + detector_id: int, workflow_ids: set[int] + ) -> list[dict[Literal["detector_id", "workflow_id"], int]]: + detector_workflows_to_add: list[dict[Literal["detector_id", "workflow_id"], int]] = [ + {"detector_id": detector_id, "workflow_id": workflow_id} + for workflow_id in workflow_ids + ] + return detector_workflows_to_add + + if update: + existing_detector_workflows = list( + DetectorWorkflow.objects.filter( + detector_id=detector_id, + ) + ) + new_workflow_ids = set(workflow_ids) - { + dw.workflow_id for dw in existing_detector_workflows + } + + detector_workflows_to_add = get_detector_workflows_to_add(detector_id, new_workflow_ids) + detector_workflows_to_remove = [ + dw for dw in existing_detector_workflows if dw.workflow_id not in workflow_ids + ] + else: + detector_workflows_to_add = get_detector_workflows_to_add( + detector_id, set(workflow_ids) + ) + detector_workflows_to_remove = [] + + perform_bulk_detector_workflow_operations( + detector_workflows_to_add=detector_workflows_to_add, + detector_workflows_to_remove=detector_workflows_to_remove, + request=request, + organization=organization, + ) + + def perform_bulk_detector_workflow_operations( detector_workflows_to_add: list[dict[Literal["detector_id", "workflow_id"], int]], detector_workflows_to_remove: Sequence[DetectorWorkflow], diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 2ea099690939..62570c583177 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -107,26 +107,30 @@ export function CommandPalette(props: CommandPaletteProps) { ); const sectionHeaderKeys = useMemo( - () => new Set(sections.map(({key}) => `section-${key}`)), + () => new Set(sections.filter(({label}) => label).map(({key}) => `section-${key}`)), [sections] ); const treeState = useTreeState({ disabledKeys: [...sectionHeaderKeys], children: sections.flatMap(({key: sectionKey, label, children}) => [ - - key={`section-${sectionKey}`} - textValue={label as string} - {...{ - label: ( - - {label} - - ), - hideCheck: true, - children: [], - }} - />, + ...(label + ? [ + + key={`section-${sectionKey}`} + textValue={label as string} + {...{ + label: ( + + {label} + + ), + hideCheck: true, + children: [], + }} + />, + ] + : []), ...children.map(({key: actionKey, ...action}) => ( key={actionKey} {...action}> {action.label} diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx index 2f3d2bd9ce43..71cb8d3966a6 100644 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ b/static/app/components/events/autofix/autofixRootCause.tsx @@ -334,8 +334,12 @@ function SolutionActionButton({ const actionLabel = needsSetup ? t('Setup %s', integration.name) : t('Send to %s', integration.name); + const textValue = hasDuplicateNames + ? `${actionLabel} (${integration.id ?? integration.provider})` + : actionLabel; return { key: `agent:${integration.id ?? integration.provider}`, + textValue, label: ( diff --git a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx index c1a9fd7ef0cb..d8db6726e967 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx @@ -3,6 +3,7 @@ import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLib import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {DiffFileType, DiffLineType} from 'sentry/components/events/autofix/types'; import { + collectPatches, isCodeChangesArtifact, isCodingAgentsArtifact, isPullRequestsArtifact, @@ -12,7 +13,7 @@ import { type RootCauseArtifact, type SolutionArtifact, } from 'sentry/components/events/autofix/useExplorerAutofix'; -import type {Artifact} from 'sentry/views/seerExplorer/types'; +import type {Artifact, ExplorerFilePatch} from 'sentry/views/seerExplorer/types'; jest.mock('sentry/actionCreators/indicator'); @@ -240,6 +241,156 @@ describe('isCodingAgentsArtifact', () => { }); }); +describe('collectPatches', () => { + function makePatch( + overrides: Partial & {repo_name: string} + ): ExplorerFilePatch { + return { + diff: 'diff content', + patch: { + added: 1, + removed: 0, + path: 'file.py', + source_file: 'file.py', + target_file: 'file.py', + type: DiffFileType.MODIFIED, + hunks: [], + }, + ...overrides, + }; + } + + it('returns an empty map for empty input', () => { + expect(collectPatches([])).toEqual(new Map()); + }); + + it('returns a single patch grouped by repo', () => { + const patch = makePatch({repo_name: 'owner/repo'}); + const result = collectPatches([patch]); + + expect(result.size).toBe(1); + expect(result.get('owner/repo')).toEqual([patch]); + }); + + it('groups multiple patches in the same repo', () => { + const patch1 = makePatch({ + repo_name: 'owner/repo', + patch: { + added: 1, + removed: 0, + path: 'a.py', + source_file: 'a.py', + target_file: 'a.py', + type: DiffFileType.MODIFIED, + hunks: [], + }, + }); + const patch2 = makePatch({ + repo_name: 'owner/repo', + patch: { + added: 2, + removed: 1, + path: 'b.py', + source_file: 'b.py', + target_file: 'b.py', + type: DiffFileType.MODIFIED, + hunks: [], + }, + }); + + const result = collectPatches([patch1, patch2]); + + expect(result.size).toBe(1); + expect(result.get('owner/repo')).toEqual([patch1, patch2]); + }); + + it('separates patches into different repos', () => { + const patch1 = makePatch({repo_name: 'owner/repo-a'}); + const patch2 = makePatch({repo_name: 'owner/repo-b'}); + + const result = collectPatches([patch1, patch2]); + + expect(result.size).toBe(2); + expect(result.get('owner/repo-a')).toEqual([patch1]); + expect(result.get('owner/repo-b')).toEqual([patch2]); + }); + + it('deduplicates by file path keeping the last occurrence', () => { + const patchOld = makePatch({ + repo_name: 'owner/repo', + diff: 'old diff', + patch: { + added: 1, + removed: 0, + path: 'file.py', + source_file: 'file.py', + target_file: 'file.py', + type: DiffFileType.MODIFIED, + hunks: [], + }, + }); + const patchNew = makePatch({ + repo_name: 'owner/repo', + diff: 'new diff', + patch: { + added: 3, + removed: 2, + path: 'file.py', + source_file: 'file.py', + target_file: 'file.py', + type: DiffFileType.MODIFIED, + hunks: [], + }, + }); + + const result = collectPatches([patchOld, patchNew]); + + expect(result.get('owner/repo')).toHaveLength(1); + expect(result.get('owner/repo')![0]!.diff).toBe('new diff'); + }); + + it('filters out no-op patches with zero added and removed', () => { + const noOpPatch = makePatch({ + repo_name: 'owner/repo', + patch: { + added: 0, + removed: 0, + path: 'file.py', + source_file: 'file.py', + target_file: 'file.py', + type: DiffFileType.MODIFIED, + hunks: [], + }, + }); + + const result = collectPatches([noOpPatch]); + + expect(result.size).toBe(0); + }); + + it('removes repos that have only no-op patches', () => { + const noOp = makePatch({ + repo_name: 'owner/empty-repo', + patch: { + added: 0, + removed: 0, + path: 'file.py', + source_file: 'file.py', + target_file: 'file.py', + type: DiffFileType.MODIFIED, + hunks: [], + }, + }); + const real = makePatch({repo_name: 'owner/real-repo'}); + + const result = collectPatches([noOp, real]); + + expect(result.size).toBe(1); + expect(result.has('owner/empty-repo')).toBe(false); + expect(result.get('owner/real-repo')).toEqual([real]); + }); +}); + describe('useExplorerAutofix - createPR', () => { const GROUP_ID = '123'; const AUTOFIX_URL = `/organizations/org-slug/issues/${GROUP_ID}/autofix/`; diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index 1b0d37740618..5f81f93b13a5 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -795,3 +795,44 @@ export function useExplorerAutofix( triggerCodingAgentHandoff, }; } + +export function collectPatches( + patches: ExplorerFilePatch[] +): Map { + const patchesByRepo = new Map(); + + for (const patch of patches) { + const existing = patchesByRepo.get(patch.repo_name) || []; + existing.push(patch); + patchesByRepo.set(patch.repo_name, existing); + } + + for (const [repoName, repoPatches] of patchesByRepo) { + const cleanedPatches = cleanPatches(repoPatches); + if (cleanedPatches.length) { + patchesByRepo.set(repoName, cleanedPatches); + } else { + patchesByRepo.delete(repoName); + } + } + + return patchesByRepo; +} + +function cleanPatches(patches: ExplorerFilePatch[]): ExplorerFilePatch[] { + const cleanedPatches: ExplorerFilePatch[] = []; + + const patchedFiles = new Set(); + + patches.toReversed().forEach(patch => { + if (patchedFiles.has(patch.patch.path)) { + return; + } + patchedFiles.add(patch.patch.path); + cleanedPatches.push(patch); + }); + + return cleanedPatches.reverse().filter(patch => { + return patch.patch.added > 0 || patch.patch.removed > 0; + }); +} diff --git a/static/app/components/events/autofix/v3/autofixCards.tsx b/static/app/components/events/autofix/v3/autofixCards.tsx index a13d58f40222..a9fc9f309dfb 100644 --- a/static/app/components/events/autofix/v3/autofixCards.tsx +++ b/static/app/components/events/autofix/v3/autofixCards.tsx @@ -1,4 +1,5 @@ import {Fragment, useEffect, useMemo, useRef, type ReactNode} from 'react'; +import styled from '@emotion/styled'; import {Tag} from '@sentry/scraps/badge'; import {Button, LinkButton} from '@sentry/scraps/button'; @@ -12,6 +13,7 @@ import { getResultButtonLabel, } from 'sentry/components/events/autofix/types'; import { + collectPatches, getAutofixArtifactFromSection, isCodeChangesArtifact, isCodingAgentsArtifact, @@ -21,7 +23,7 @@ import { type AutofixSection, type useExplorerAutofix, } from 'sentry/components/events/autofix/useExplorerAutofix'; -import {Placeholder} from 'sentry/components/placeholder'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {TimeSince} from 'sentry/components/timeSince'; import {IconRefresh} from 'sentry/icons'; import {IconBot} from 'sentry/icons/iconBot'; @@ -33,7 +35,6 @@ import {IconPullRequest} from 'sentry/icons/iconPullRequest'; import {t, tct, tn} from 'sentry/locale'; import {defined} from 'sentry/utils'; import {FileDiffViewer} from 'sentry/views/seerExplorer/fileDiffViewer'; -import {type ExplorerFilePatch} from 'sentry/views/seerExplorer/types'; interface AutofixCardProps { autofix: ReturnType; @@ -46,13 +47,15 @@ export function RootCauseCard({autofix, section}: AutofixCardProps) { return isRootCauseArtifact(sectionArtifact) ? sectionArtifact : null; }, [section]); - const {runState, startStep} = autofix; - const runId = runState?.run_id; + const {startStep} = autofix; return ( } title={t('Root Cause')}> {section.status === 'processing' ? ( - + ) : artifact?.data ? ( {artifact.data.one_line_description} @@ -94,7 +97,7 @@ export function RootCauseCard({autofix, section}: AutofixCardProps) { @@ -117,7 +120,10 @@ export function SolutionCard({autofix, section}: AutofixCardProps) { return ( } title={t('Plan')}> {section.status === 'processing' ? ( - + ) : artifact?.data ? ( {artifact.data.one_line_summary} @@ -167,22 +173,14 @@ export function CodeChangesCard({autofix, section}: AutofixCardProps) { return isCodeChangesArtifact(sectionArtifact) ? sectionArtifact : null; }, [section]); - const patchesForRepos = useMemo(() => { - const patchesByRepo = new Map(); - for (const patch of artifact ?? []) { - const existing = patchesByRepo.get(patch.repo_name) || []; - existing.push(patch); - patchesByRepo.set(patch.repo_name, existing); - } - return patchesByRepo; - }, [artifact]); + const patchesByRepo = useMemo(() => collectPatches(artifact ?? []), [artifact]); const summary = useMemo(() => { - const reposChanged = patchesForRepos.size; + const reposChanged = patchesByRepo.size; const filesChanged = new Set(); - for (const [repo, patchesForRepo] of patchesForRepos.entries()) { + for (const [repo, patchesForRepo] of patchesByRepo.entries()) { for (const patch of patchesForRepo) { filesChanged.add(`${repo}:${patch.patch.path}`); } @@ -197,7 +195,7 @@ export function CodeChangesCard({autofix, section}: AutofixCardProps) { } return t('%s files changed in %s repos', filesChanged.size, reposChanged); - }, [patchesForRepos]); + }, [patchesByRepo]); const {runState, startStep} = autofix; const runId = runState?.run_id; @@ -205,13 +203,16 @@ export function CodeChangesCard({autofix, section}: AutofixCardProps) { return ( } title={t('Code Changes')}> {section.status === 'processing' ? ( - + ) : ( - {patchesForRepos.size ? ( + {patchesByRepo.size ? ( {summary} - {[...patchesForRepos.entries()].map(([repo, patches]) => ( + {[...patchesByRepo.entries()].map(([repo, patches]) => ( {t('Repository:')} @@ -401,10 +402,11 @@ function ArtifactDetails({children, ...flexProps}: ArtifactDetailsProps) { } interface LoadingDetailsProps { + loadingMessage: string; messages: AutofixSection['messages']; } -function LoadingDetails({messages}: LoadingDetailsProps) { +function LoadingDetails({loadingMessage, messages}: LoadingDetailsProps) { const containerRef = useRef(null); const bottomRef = useRef(null); @@ -448,10 +450,15 @@ function LoadingDetails({messages}: LoadingDetailsProps) { return null; })} -
- -
+ + + {loadingMessage} +
); } + +const StyledLoadingIndicator = styled(LoadingIndicator)` + margin: 0; +`; diff --git a/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx b/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx index b7b1e87eab1a..f9a35f3a0130 100644 --- a/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx +++ b/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx @@ -47,12 +47,12 @@ describe('RootCausePreview', () => { expect(screen.getByText('Null pointer in user handler')).toBeInTheDocument(); }); - it('renders placeholder when processing', () => { + it('renders loading text when processing', () => { render( ); - expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument(); + expect(screen.getByText('Finding the root cause…')).toBeInTheDocument(); }); it('handles null data', () => { @@ -90,12 +90,12 @@ describe('SolutionPreview', () => { expect(screen.getByText('Add null check before accessing user')).toBeInTheDocument(); }); - it('renders placeholder when processing', () => { + it('renders loading text when processing', () => { render( ); - expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument(); + expect(screen.getByText('Formulating a plan…')).toBeInTheDocument(); }); it('handles null data', () => { @@ -176,14 +176,14 @@ describe('CodeChangesPreview', () => { expect(screen.getByText('3 files changed in 2 repos')).toBeInTheDocument(); }); - it('renders placeholder when processing', () => { + it('renders loading text when processing', () => { render( ); - expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument(); + expect(screen.getByText('Implementing changes…')).toBeInTheDocument(); }); it('renders empty array with error message', () => { diff --git a/static/app/components/events/autofix/v3/autofixPreviews.tsx b/static/app/components/events/autofix/v3/autofixPreviews.tsx index 1ab9f0499f1f..d9bb96d6bd72 100644 --- a/static/app/components/events/autofix/v3/autofixPreviews.tsx +++ b/static/app/components/events/autofix/v3/autofixPreviews.tsx @@ -1,4 +1,5 @@ import {useMemo, type ReactNode} from 'react'; +import styled from '@emotion/styled'; import {Tag} from '@sentry/scraps/badge'; import {LinkButton} from '@sentry/scraps/button'; @@ -11,6 +12,7 @@ import { getCodingAgentName, } from 'sentry/components/events/autofix/types'; import { + collectPatches, getAutofixArtifactFromSection, isCodeChangesArtifact, isCodingAgentsArtifact, @@ -19,6 +21,7 @@ import { isSolutionArtifact, type AutofixSection, } from 'sentry/components/events/autofix/useExplorerAutofix'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Placeholder} from 'sentry/components/placeholder'; import {IconOpen} from 'sentry/icons'; import {IconBot} from 'sentry/icons/iconBot'; @@ -27,7 +30,6 @@ import {IconCode} from 'sentry/icons/iconCode'; import {IconList} from 'sentry/icons/iconList'; import {IconPullRequest} from 'sentry/icons/iconPullRequest'; import {t, tn} from 'sentry/locale'; -import {type ExplorerFilePatch} from 'sentry/views/seerExplorer/types'; interface ArtifactPreviewProps { section: AutofixSection; @@ -42,7 +44,10 @@ export function RootCausePreview({section}: ArtifactPreviewProps) { return ( } title={t('Root Cause')}> {section.status === 'processing' ? ( - + + + {t('Finding the root cause\u2026')} + ) : artifact?.data ? ( {artifact.data.one_line_description} ) : ( @@ -65,7 +70,10 @@ export function SolutionPreview({section}: ArtifactPreviewProps) { return ( } title={t('Plan')}> {section.status === 'processing' ? ( - + + + {t('Formulating a plan\u2026')} + ) : artifact?.data ? ( {artifact.data.one_line_summary} ) : ( @@ -83,22 +91,14 @@ export function CodeChangesPreview({section}: ArtifactPreviewProps) { return isCodeChangesArtifact(sectionArtifact) ? sectionArtifact : []; }, [section]); - const patchesForRepos = useMemo(() => { - const patchesByRepo = new Map(); - for (const patch of artifact) { - const existing = patchesByRepo.get(patch.repo_name) || []; - existing.push(patch); - patchesByRepo.set(patch.repo_name, existing); - } - return patchesByRepo; - }, [artifact]); + const patchesByRepo = useMemo(() => collectPatches(artifact ?? []), [artifact]); const summary = useMemo(() => { - const reposChanged = patchesForRepos.size; + const reposChanged = patchesByRepo.size; const filesChanged = new Set(); - for (const [repo, patchesForRepo] of patchesForRepos.entries()) { + for (const [repo, patchesForRepo] of patchesByRepo.entries()) { for (const patch of patchesForRepo) { filesChanged.add(`${repo}:${patch.patch.path}`); } @@ -129,11 +129,18 @@ export function CodeChangesPreview({section}: ArtifactPreviewProps) { return ( {t('%s files changed in %s repos', filesChanged.size, reposChanged)} ); - }, [patchesForRepos]); + }, [patchesByRepo]); return ( } title={t('Code Changes')}> - {section.status === 'processing' ? : summary} + {section.status === 'processing' ? ( + + + {t('Implementing changes\u2026')} + + ) : ( + summary + )} ); } @@ -236,3 +243,7 @@ function ArtifactCard({children, icon, title}: ArtifactCardProps) {
); } + +const StyledLoadingIndicator = styled(LoadingIndicator)` + margin: 0; +`; diff --git a/static/app/components/events/autofix/v3/nextStep.tsx b/static/app/components/events/autofix/v3/nextStep.tsx index f5c5579f4f90..7ae3a8c01976 100644 --- a/static/app/components/events/autofix/v3/nextStep.tsx +++ b/static/app/components/events/autofix/v3/nextStep.tsx @@ -289,6 +289,7 @@ function NextStepTemplate({ return { key: `agent:${integration.id ?? integration.provider}`, + textValue: actionLabel, label: ( diff --git a/static/app/components/layouts/thirds.tsx b/static/app/components/layouts/thirds.tsx index 7ecbfaa0acdf..65da2f90be5a 100644 --- a/static/app/components/layouts/thirds.tsx +++ b/static/app/components/layouts/thirds.tsx @@ -26,28 +26,9 @@ export function Page(props: FlexProps<'main'> & {withPadding?: boolean}) { if (hasPageFrame) { return ( - & {withPadding?: boolean}) { ); } -const StyledPageFrameStack = styled(Stack)<{roundedCorner: boolean}>` - > :first-child { - border-top-left-radius: ${p => (p.roundedCorner ? p.theme.radius.lg : undefined)}; - } -`; - /** * Header container for header content and header actions. * @@ -83,8 +58,18 @@ const StyledPageFrameStack = styled(Stack)<{roundedCorner: boolean}>` */ export const Header = styled((props: ContainerProps<'header'>) => { const hasPageFrame = useHasPageFrameFeature(); + return ( - + ); })<{ borderStyle?: 'dashed' | 'solid'; @@ -100,8 +85,6 @@ export const Header = styled((props: ContainerProps<'header'>) => { grid-template-columns: ${p => p.noActionWrap ? 'minmax(0, 1fr) auto' : 'minmax(0, 1fr)'}; - padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0 ${p => p.theme.space.xl}; - ${p => !p.unified && css` @@ -109,8 +92,6 @@ export const Header = styled((props: ContainerProps<'header'>) => { `} @media (min-width: ${p => p.theme.breakpoints.md}) { - padding: ${p => p.theme.space.xl} ${p => p.theme.space['3xl']} 0 - ${p => p.theme.space['3xl']}; grid-template-columns: minmax(0, 1fr) auto; } `; @@ -123,14 +104,8 @@ export const HeaderContent = styled('div')<{unified?: boolean}>` display: flex; flex-direction: column; justify-content: normal; - margin-bottom: ${p => p.theme.space.md}; + margin-bottom: ${p => (p.unified ? 0 : p.theme.space.md)}; max-width: 100%; - - ${p => - p.unified && - css` - margin-bottom: 0; - `} `; /** @@ -191,19 +166,22 @@ export const HeaderTabs = styled(Tabs)` /** * Base container for 66/33 containers. */ -export const Body = styled('div')<{noRowGap?: boolean}>` - padding: ${p => p.theme.space.xl}; - margin: 0; - background-color: ${p => p.theme.tokens.background.primary}; +export const Body = styled((props: ContainerProps<'div'> & {noRowGap?: boolean}) => { + const hasPageFrame = useHasPageFrameFeature(); + return ( + + ); +})<{noRowGap?: boolean}>` flex-grow: 1; - @media (min-width: ${p => p.theme.breakpoints.md}) { - padding: ${p => - p.noRowGap - ? `${p.theme.space.xl} ${p.theme.space['3xl']}` - : `${p.theme.space['2xl']} ${p.theme.space['3xl']}`}; - } - @media (min-width: ${p => p.theme.breakpoints.lg}) { display: grid; grid-template-columns: minmax(100px, auto) 325px; diff --git a/static/app/components/onboarding/productSelection.tsx b/static/app/components/onboarding/productSelection.tsx index 7cc88cc02306..f5431ba69416 100644 --- a/static/app/components/onboarding/productSelection.tsx +++ b/static/app/components/onboarding/productSelection.tsx @@ -489,7 +489,7 @@ export const platformProductAvailability = { ProductSolution.PROFILING, ProductSolution.LOGS, ], - unity: [ProductSolution.LOGS], + unity: [ProductSolution.LOGS, ProductSolution.METRICS], unreal: [ProductSolution.LOGS], } as Record; diff --git a/static/app/components/stream/supergroupRow.tsx b/static/app/components/stream/supergroupRow.tsx index 6f07d00c579e..dff3ee828bbb 100644 --- a/static/app/components/stream/supergroupRow.tsx +++ b/static/app/components/stream/supergroupRow.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import InteractionStateLayer from '@sentry/scraps/interactionStateLayer'; import {Text} from '@sentry/scraps/text'; +import type {IndexedMembersByProject} from 'sentry/actionCreators/members'; import {GroupStatusChart} from 'sentry/components/charts/groupStatusChart'; import {Count} from 'sentry/components/count'; import {useDrawer} from 'sentry/components/globalDrawer'; @@ -18,25 +19,37 @@ import {SupergroupDetailDrawer} from 'sentry/views/issueList/supergroups/supergr import type {SupergroupDetail} from 'sentry/views/issueList/supergroups/types'; interface SupergroupRowProps { - matchedCount: number; + matchedGroupIds: string[]; supergroup: SupergroupDetail; aggregatedStats?: AggregatedSupergroupStats | null; + memberList?: IndexedMembersByProject; } export function SupergroupRow({ supergroup, - matchedCount, + matchedGroupIds, aggregatedStats, + memberList, }: SupergroupRowProps) { + const matchedCount = matchedGroupIds.length; const {openDrawer, isDrawerOpen} = useDrawer(); const [isActive, setIsActive] = useState(false); const handleClick = () => { setIsActive(true); - openDrawer(() => , { - ariaLabel: t('Supergroup details'), - drawerKey: 'supergroup-drawer', - onClose: () => setIsActive(false), - }); + openDrawer( + () => ( + + ), + { + ariaLabel: t('Supergroup details'), + drawerKey: 'supergroup-drawer', + onClose: () => setIsActive(false), + } + ); }; const highlighted = isActive && isDrawerOpen; diff --git a/static/app/components/workflowEngine/ui/detailSection.tsx b/static/app/components/workflowEngine/ui/detailSection.tsx new file mode 100644 index 000000000000..76a5876587a1 --- /dev/null +++ b/static/app/components/workflowEngine/ui/detailSection.tsx @@ -0,0 +1,26 @@ +import {Flex} from '@sentry/scraps/layout'; +import {Heading} from '@sentry/scraps/text'; + +type DetailSectionProps = { + title: React.ReactNode; + children?: React.ReactNode; + className?: string; + trailingItems?: React.ReactNode; +}; + +export function DetailSection({ + children, + className, + title, + trailingItems, +}: DetailSectionProps) { + return ( + + + {title} + {trailingItems ?? null} + + {children} + + ); +} diff --git a/static/app/components/workflowEngine/ui/formSection.tsx b/static/app/components/workflowEngine/ui/formSection.tsx new file mode 100644 index 000000000000..93b5fa4b34ed --- /dev/null +++ b/static/app/components/workflowEngine/ui/formSection.tsx @@ -0,0 +1,56 @@ +import styled from '@emotion/styled'; + +import {Disclosure} from '@sentry/scraps/disclosure'; +import {Flex} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +type FormSectionProps = { + title: React.ReactNode; + children?: React.ReactNode; + className?: string; + defaultExpanded?: boolean; + description?: React.ReactNode; + trailingItems?: React.ReactNode; +}; + +export function FormSection({ + children, + className, + title, + description, + trailingItems, + defaultExpanded = true, +}: FormSectionProps) { + return ( + + + {title} + + + + {description && ( + + {description} + + )} + {children} + + + + ); +} + +export function FormSectionSubHeading({children}: {children: React.ReactNode}) { + return {children}; +} + +// The Disclosure adds padding to the title so we need a negative margin to visually align the description with the title +const FormSectionDescription = styled(Text)` + margin: -${p => p.theme.space.md} 0 0 0; +`; diff --git a/static/app/components/workflowEngine/ui/section.tsx b/static/app/components/workflowEngine/ui/section.tsx deleted file mode 100644 index 993e3aa410cb..000000000000 --- a/static/app/components/workflowEngine/ui/section.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import styled from '@emotion/styled'; - -import {Flex} from '@sentry/scraps/layout'; -import {Heading} from '@sentry/scraps/text'; - -type SectionProps = { - title: React.ReactNode; - children?: React.ReactNode; - className?: string; - description?: React.ReactNode; - trailingItems?: React.ReactNode; -}; - -export function Section({ - children, - className, - title, - description, - trailingItems, -}: SectionProps) { - return ( - - - {title} - {trailingItems && {trailingItems}} - - {description && {description}} - {children} - - ); -} - -export const SectionSubHeading = styled('h5')` - font-size: ${p => p.theme.font.size.md}; - font-weight: ${p => p.theme.font.weight.sans.medium}; - margin: 0; -`; - -const SectionDescription = styled('div')` - font-size: ${p => p.theme.font.size.md}; - font-weight: ${p => p.theme.font.weight.sans.regular}; - color: ${p => p.theme.tokens.content.secondary}; - margin: 0; -`; - -const SectionContainer = styled(Flex)` - > ${SectionDescription} { - margin-bottom: ${p => p.theme.space['0']}; - } - - ${SectionDescription} + ${SectionDescription} { - margin-top: ${p => p.theme.space.md}; - } -`; - -// moved above to reference in SectionContainer diff --git a/static/app/data/controlsiloUrlPatterns.ts b/static/app/data/controlsiloUrlPatterns.ts index 58fef1b86fe6..9162cb860102 100644 --- a/static/app/data/controlsiloUrlPatterns.ts +++ b/static/app/data/controlsiloUrlPatterns.ts @@ -72,6 +72,7 @@ export const controlsiloUrlPatterns: RegExp[] = [ new RegExp('^api/0/organizations/[^/]+/api-keys/[^/]+/$'), new RegExp('^api/0/organizations/[^/]+/audit-logs/$'), new RegExp('^api/0/organizations/[^/]+/integrations/$'), + new RegExp('^api/0/organizations/[^/]+/integrations/direct-enable/[^/]+/$'), new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/$'), new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/channels/$'), new RegExp('^api/0/organizations/[^/]+/integrations/[^/]+/channel-validate/$'), diff --git a/static/app/data/platformCategories.tsx b/static/app/data/platformCategories.tsx index 7ea6da29ccc3..5792f74e5673 100644 --- a/static/app/data/platformCategories.tsx +++ b/static/app/data/platformCategories.tsx @@ -492,6 +492,7 @@ export const withMetricsOnboarding = new Set([ 'ruby', 'ruby-rack', 'ruby-rails', + 'unity', ]); // List of platforms that do not have metrics support. We make use of this list in the product to not provide any Metrics diff --git a/static/app/gettingStartedDocs/unity/index.tsx b/static/app/gettingStartedDocs/unity/index.tsx index c52f302fd9e9..c6d03a345780 100644 --- a/static/app/gettingStartedDocs/unity/index.tsx +++ b/static/app/gettingStartedDocs/unity/index.tsx @@ -2,6 +2,7 @@ import type {Docs} from 'sentry/components/onboarding/gettingStartedDoc/types'; import {crashReport} from './crashReport'; import {logs} from './logs'; +import {metrics} from './metrics'; import {onboarding} from './onboarding'; export const docs: Docs = { @@ -9,4 +10,5 @@ export const docs: Docs = { feedbackOnboardingCrashApi: crashReport, crashReportOnboarding: crashReport, logsOnboarding: logs, + metricsOnboarding: metrics, }; diff --git a/static/app/gettingStartedDocs/unity/metrics.spec.tsx b/static/app/gettingStartedDocs/unity/metrics.spec.tsx new file mode 100644 index 000000000000..041c53830e41 --- /dev/null +++ b/static/app/gettingStartedDocs/unity/metrics.spec.tsx @@ -0,0 +1,42 @@ +import {ProjectFixture} from 'sentry-fixture/project'; + +import {renderWithOnboardingLayout} from 'sentry-test/onboarding/renderWithOnboardingLayout'; +import {screen} from 'sentry-test/reactTestingLibrary'; +import {textWithMarkupMatcher} from 'sentry-test/utils'; + +import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; + +import {docs} from '.'; + +function renderMockRequests() { + MockApiClient.addMockResponse({ + url: '/projects/org-slug/project-slug/', + body: [ProjectFixture()], + }); +} + +describe('metrics', () => { + it('unity metrics onboarding docs', () => { + renderMockRequests(); + + renderWithOnboardingLayout(docs, { + selectedProducts: [ProductSolution.METRICS], + }); + + expect( + screen.getByText(textWithMarkupMatcher(/SentrySdk\.Metrics\.Increment/)) + ).toBeInTheDocument(); + }); + + it('does not render metrics configuration when metrics is not enabled', () => { + renderMockRequests(); + + renderWithOnboardingLayout(docs, { + selectedProducts: [], + }); + + expect( + screen.queryByText(textWithMarkupMatcher(/SentrySdk\.Metrics\.Increment/)) + ).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/gettingStartedDocs/unity/metrics.tsx b/static/app/gettingStartedDocs/unity/metrics.tsx new file mode 100644 index 000000000000..9da762075eb9 --- /dev/null +++ b/static/app/gettingStartedDocs/unity/metrics.tsx @@ -0,0 +1,106 @@ +import {ExternalLink} from '@sentry/scraps/link'; + +import type { + ContentBlock, + DocsParams, + OnboardingConfig, +} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import {StepType} from 'sentry/components/onboarding/gettingStartedDoc/types'; +import {t, tct} from 'sentry/locale'; + +export const metricsVerify = (params: DocsParams): ContentBlock => ({ + type: 'conditional', + condition: params.isMetricsSelected, + content: [ + { + type: 'text', + text: t( + 'Send test metrics from your app to verify metrics are arriving in Sentry.' + ), + }, + { + type: 'code', + language: 'csharp', + code: `using Sentry; + +SentrySdk.Metrics.Increment("player_interaction", + tags: new Dictionary {{"action", "jump"}, {"scene", "main_menu"}}); +SentrySdk.Metrics.Distribution("scene_load", 230, + unit: MeasurementUnit.Duration.Millisecond, + tags: new Dictionary {{"scene", "world_1"}}); +SentrySdk.Metrics.Gauge("active_players", 42, + tags: new Dictionary {{"server", "us-east-1"}});`, + }, + { + type: 'text', + text: tct('For more detailed information, see the [link:metrics documentation].', { + link: , + }), + }, + ], +}); + +export const metrics: OnboardingConfig = { + install: () => [ + { + type: StepType.INSTALL, + content: [ + { + type: 'text', + text: tct( + 'Metrics for Unity are supported in Sentry SDK version [code:4.1.0] and above.', + { + code: , + } + ), + }, + ], + }, + ], + configure: (params: DocsParams) => [ + { + type: StepType.CONFIGURE, + content: [ + { + type: 'text', + text: t( + 'To enable metrics in your Unity game, you need to configure the Sentry SDK with metrics enabled.' + ), + }, + { + type: 'text', + text: tct( + 'Open your project settings: [strong:Tools > Sentry > Advanced > Metrics] and check the [strong:Enable Metrics] option.', + { + strong: , + } + ), + }, + { + type: 'text', + text: t('Alternatively, you can enable metrics programmatically:'), + }, + { + type: 'code', + language: 'csharp', + code: `SentrySdk.Init(options => +{ + options.Dsn = "${params.dsn.public}"; + + // Enable metrics to be sent to Sentry + options.ExperimentalMetrics = new ExperimentalMetricsOptions + { + EnableCodeLocations = true + }; +});`, + }, + ], + }, + ], + verify: (params: DocsParams) => [ + { + type: StepType.VERIFY, + content: [metricsVerify(params)], + }, + ], +}; diff --git a/static/app/gettingStartedDocs/unity/onboarding.spec.tsx b/static/app/gettingStartedDocs/unity/onboarding.spec.tsx index 0ee5a30a6bfa..421d56c530be 100644 --- a/static/app/gettingStartedDocs/unity/onboarding.spec.tsx +++ b/static/app/gettingStartedDocs/unity/onboarding.spec.tsx @@ -4,6 +4,8 @@ import {renderWithOnboardingLayout} from 'sentry-test/onboarding/renderWithOnboa import {screen} from 'sentry-test/reactTestingLibrary'; import {textWithMarkupMatcher} from 'sentry-test/utils'; +import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; + import {docs} from '.'; function renderMockRequests() { @@ -32,4 +34,16 @@ describe('unity onboarding docs', () => { ) ).toBeInTheDocument(); }); + + it('renders metrics snippet when metrics product is selected', () => { + renderMockRequests(); + + renderWithOnboardingLayout(docs, { + selectedProducts: [ProductSolution.METRICS], + }); + + expect( + screen.getByText(textWithMarkupMatcher(/SentrySdk\.Metrics\.Increment/)) + ).toBeInTheDocument(); + }); }); diff --git a/static/app/gettingStartedDocs/unity/onboarding.tsx b/static/app/gettingStartedDocs/unity/onboarding.tsx index 6ac25fed2f80..b3123a465194 100644 --- a/static/app/gettingStartedDocs/unity/onboarding.tsx +++ b/static/app/gettingStartedDocs/unity/onboarding.tsx @@ -11,6 +11,7 @@ import {getConsoleExtensions} from 'sentry/components/onboarding/gettingStartedD import {t, tct} from 'sentry/locale'; import {logsVerify} from './logs'; +import {metricsVerify} from './metrics'; const getVerifySnippet = () => ` using Sentry; // On the top of the script @@ -74,6 +75,19 @@ export const onboarding: OnboardingConfig = { }, ], }, + { + type: 'conditional', + condition: params.isMetricsSelected, + content: [ + { + type: 'text', + text: tct( + 'To enable metrics, navigate to [strong:Tools > Sentry > Advanced > Metrics] and check the [strong:Enable Metrics] option.', + {strong: } + ), + }, + ], + }, { type: 'text', text: tct( @@ -113,6 +127,14 @@ export const onboarding: OnboardingConfig = { }, ] satisfies OnboardingStep[]) : []), + ...(params.isMetricsSelected + ? ([ + { + title: t('Metrics'), + content: [metricsVerify(params)], + }, + ] satisfies OnboardingStep[]) + : []), { title: t('Troubleshooting'), content: [ diff --git a/static/app/index.tsx b/static/app/index.tsx index cce998728cde..f17d468faef1 100644 --- a/static/app/index.tsx +++ b/static/app/index.tsx @@ -85,9 +85,9 @@ async function app() { // We have split up the imports this way so that locale is initialized as // early as possible, (e.g. before `registerHooks` is imported otherwise the // imports in `registerHooks` will not be in the correct locale. - // eslint-disable-next-line boundaries/element-types -- getsentry entrypoint + // eslint-disable-next-line boundaries/dependencies -- getsentry entrypoint const registerHooksImport = import('getsentry/registerHooks'); - // eslint-disable-next-line boundaries/element-types -- getsentry entrypoint + // eslint-disable-next-line boundaries/dependencies -- getsentry entrypoint const initalizeBundleMetricsImport = import('getsentry/initializeBundleMetrics'); // getsentry augments Sentry's application through a 'hook' mechanism. Sentry diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 6214d45007a5..a4845e4ca8dd 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -309,12 +309,12 @@ function buildRoutes(): RouteObject[] { { path: '/stories/*', withOrgPath: true, - // eslint-disable-next-line boundaries/element-types -- storybook entrypoint + // eslint-disable-next-line boundaries/dependencies -- storybook entrypoint component: make(() => import('sentry/stories/view/index')), }, { path: '/debug/notifications/:notificationSource?/', - // eslint-disable-next-line boundaries/element-types -- debug tools entrypoint + // eslint-disable-next-line boundaries/dependencies -- debug tools entrypoint component: make(() => import('sentry/debug/notifications/views/index')), withOrgPath: true, }, @@ -569,7 +569,7 @@ function buildRoutes(): RouteObject[] { { path: 'seer/', name: t('Seer'), - // eslint-disable-next-line boundaries/element-types -- TODO: move to getsentry routes + // eslint-disable-next-line boundaries/dependencies -- TODO: move to getsentry routes component: make(() => import('getsentry/views/seerAutomation/projectDetails')), }, { diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index 1123f243ba52..5cb3ffea2c0f 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -101,6 +101,7 @@ export interface Organization extends OrganizationSummary { teamRoleList: TeamRole[]; trustedRelays: Relay[]; consoleSdkInviteQuota?: number; + dashboardsAsyncQueueParallelLimit?: number; defaultAutofixAutomationTuning?: | 'off' | 'super_low' diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 9bd1a50fefc8..7368609bbf53 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -361,6 +361,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/' | '/organizations/$organizationIdOrSlug/integrations/$integrationId/serverless-functions/' | '/organizations/$organizationIdOrSlug/integrations/coding-agents/' + | '/organizations/$organizationIdOrSlug/integrations/direct-enable/$providerKey/' | '/organizations/$organizationIdOrSlug/intercom-jwt/' | '/organizations/$organizationIdOrSlug/invite-requests/' | '/organizations/$organizationIdOrSlug/invite-requests/$memberId/' diff --git a/static/app/views/automations/components/automationForm.tsx b/static/app/views/automations/components/automationForm.tsx index 7d3a175d0d29..d34df420fee2 100644 --- a/static/app/views/automations/components/automationForm.tsx +++ b/static/app/views/automations/components/automationForm.tsx @@ -6,7 +6,7 @@ import type {FormModel} from 'sentry/components/forms/model'; import {EnvironmentSelector} from 'sentry/components/workflowEngine/form/environmentSelector'; import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; import {Card} from 'sentry/components/workflowEngine/ui/card'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {t} from 'sentry/locale'; import type {Automation} from 'sentry/types/workflowEngine/automations'; import {AutomationBuilder} from 'sentry/views/automations/components/automationBuilder'; @@ -32,27 +32,27 @@ export function AutomationForm({model}: {model: FormModel}) { setConnectedIds={setConnectedIds} /> -
-
+
-
+ -
+
-
-
+
); diff --git a/static/app/views/automations/components/editConnectedMonitors.tsx b/static/app/views/automations/components/editConnectedMonitors.tsx index 8a5c410e4739..1641e602dd10 100644 --- a/static/app/views/automations/components/editConnectedMonitors.tsx +++ b/static/app/views/automations/components/editConnectedMonitors.tsx @@ -14,7 +14,7 @@ import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPa import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {Placeholder} from 'sentry/components/placeholder'; import {Container as WorkflowEngineContainer} from 'sentry/components/workflowEngine/ui/container'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {IconAdd, IconEdit} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Automation} from 'sentry/types/workflowEngine/automations'; @@ -97,7 +97,7 @@ function AllMonitors({ return ( -
+
@@ -116,7 +116,7 @@ function AllMonitors({ projectIds={selection.projects} openInNewTab /> -
+
); } @@ -315,7 +315,7 @@ function EditConnectedMonitorsContent({ return ( -
+ )} -
+
); } @@ -369,9 +369,9 @@ export function EditConnectedMonitors({connectedIds, setConnectedIds}: Props) { if (isLoading && firstLoad) { return ( -
+ -
+
); } @@ -392,6 +392,6 @@ const DrawerContent = styled('div')` padding: ${p => p.theme.space.xl} ${p => p.theme.space['3xl']}; `; -const StyledSection = styled(Section)` +const StyledSection = styled(FormSection)` margin-bottom: ${p => p.theme.space.lg}; `; diff --git a/static/app/views/automations/detail.tsx b/static/app/views/automations/detail.tsx index 1f63be36ac67..e60b3783312f 100644 --- a/static/app/views/automations/detail.tsx +++ b/static/app/views/automations/detail.tsx @@ -18,7 +18,7 @@ import {Placeholder} from 'sentry/components/placeholder'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {TimeSince} from 'sentry/components/timeSince'; import {DetailLayout} from 'sentry/components/workflowEngine/layout/detail'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {IconEdit} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {Automation} from 'sentry/types/workflowEngine/automations'; @@ -93,7 +93,7 @@ function AutomationDetailContent({automation}: {automation: Automation}) { utc={utc ?? null} /> -
+ -
-
+ + -
+ -
+ {automation.lastTriggered ? ( @@ -129,24 +129,24 @@ function AutomationDetailContent({automation}: {automation: Automation}) { ) : ( t('Never') )} -
-
+ + {automation.environment || t('All environments')} -
-
+ + {tct('Every [frequency]', { frequency: getDuration((automation.config.frequency || 0) * 60), })} -
-
+ + -
-
+ + -
+
diff --git a/static/app/views/dashboards/createFromSeer.tsx b/static/app/views/dashboards/createFromSeer.tsx index be8d41746e63..8786524df958 100644 --- a/static/app/views/dashboards/createFromSeer.tsx +++ b/static/app/views/dashboards/createFromSeer.tsx @@ -19,6 +19,7 @@ import type {SeerExplorerResponse} from 'sentry/views/seerExplorer/hooks/useSeer import {makeSeerExplorerQueryKey} from 'sentry/views/seerExplorer/utils'; import {WidgetErrorProvider} from './contexts/widgetErrorContext'; +import {applySeerWidgetDefaults} from './createFromSeerUtils'; import {DashboardChatPanel, type WidgetError} from './dashboardChatPanel'; import {EMPTY_DASHBOARD} from './data'; import {DashboardDetailWithInjectedProps as DashboardDetail} from './detail'; @@ -81,7 +82,7 @@ function extractDashboardFromSession( return { title: data.title, widgets: assignDefaultLayout( - data.widgets.map(normalizeWidget).map(assignTempId), + applySeerWidgetDefaults(data.widgets.map(normalizeWidget)).map(assignTempId), getInitialColumnDepths() ), }; diff --git a/static/app/views/dashboards/createFromSeerUtils.spec.tsx b/static/app/views/dashboards/createFromSeerUtils.spec.tsx new file mode 100644 index 000000000000..66308db5ef45 --- /dev/null +++ b/static/app/views/dashboards/createFromSeerUtils.spec.tsx @@ -0,0 +1,53 @@ +import {applySeerWidgetDefaults} from 'sentry/views/dashboards/createFromSeerUtils'; +import type {Widget} from 'sentry/views/dashboards/types'; +import {DisplayType} from 'sentry/views/dashboards/types'; + +function makeWidget(overrides: Partial = {}): Widget { + return { + displayType: DisplayType.LINE, + interval: '1h', + title: 'Test Widget', + queries: [ + { + name: '', + conditions: '', + aggregates: ['count()'], + columns: [], + fields: ['count()'], + orderby: '', + }, + ], + ...overrides, + }; +} + +describe('applySeerWidgetDefaults', () => { + describe('layout defaults', () => { + it('fills in a full default layout when layout is undefined', () => { + const widgets = [makeWidget({layout: undefined})]; + const [result] = applySeerWidgetDefaults(widgets); + + expect(result!.layout).toEqual({x: 0, y: 0, w: 2, h: 2, minH: 2}); + }); + + it('fills in minH when it is missing from an existing layout', () => { + const widgets = [ + makeWidget({ + layout: {x: 1, y: 2, w: 3, h: 4} as Widget['layout'], + }), + ]; + const [result] = applySeerWidgetDefaults(widgets); + + expect(result!.layout).toEqual({x: 1, y: 2, w: 3, h: 4, minH: 2}); + }); + }); + + describe('limit defaults', () => { + it('fills in default limit when limit is undefined', () => { + const widgets = [makeWidget({limit: undefined})]; + const [result] = applySeerWidgetDefaults(widgets); + + expect(result!.limit).toBe(5); + }); + }); +}); diff --git a/static/app/views/dashboards/createFromSeerUtils.tsx b/static/app/views/dashboards/createFromSeerUtils.tsx new file mode 100644 index 000000000000..4b25144ff330 --- /dev/null +++ b/static/app/views/dashboards/createFromSeerUtils.tsx @@ -0,0 +1,54 @@ +import { + DEFAULT_WIDGET_WIDTH, + getDefaultWidgetHeight, +} from 'sentry/views/dashboards/layoutUtils'; +import type {Widget, WidgetLayout} from 'sentry/views/dashboards/types'; + +const DEFAULT_LIMIT = 5; + +/** + * This function serves as a fallback to fill out any commonly missing or transform + * any invalid attributes generated by Seer. For example, although attributes such + * as limit and minH are specified as required by our schema, Seer does not always + * generate them in practice. This function as a last step helps mitigate issues + * where dashboards fail to save due to widgets falling into these scenarios. + */ +export function applySeerWidgetDefaults(widgets: Widget[]): Widget[] { + return widgets.map(widget => { + const layout = applyLayoutDefaults(widget.layout, widget.displayType); + const limit = applyLimitDefaults(widget.limit); + + return { + ...widget, + layout, + limit, + }; + }); +} + +function applyLayoutDefaults( + layout: WidgetLayout | null | undefined, + displayType: Widget['displayType'] +): WidgetLayout { + const defaultMinH = getDefaultWidgetHeight(displayType); + if (!layout) { + return { + w: DEFAULT_WIDGET_WIDTH, + h: defaultMinH, + x: 0, + y: 0, + minH: defaultMinH, + }; + } + return { + ...layout, + minH: layout.minH ?? defaultMinH, + }; +} + +function applyLimitDefaults(limit: number | null | undefined): number { + if (limit === null || limit === undefined) { + return DEFAULT_LIMIT; + } + return limit; +} diff --git a/static/app/views/dashboards/manage/index.tsx b/static/app/views/dashboards/manage/index.tsx index 54b2fed0369b..584308e6e56f 100644 --- a/static/app/views/dashboards/manage/index.tsx +++ b/static/app/views/dashboards/manage/index.tsx @@ -668,6 +668,7 @@ function ManageDashboards() { }, { key: 'create-dashboard-agent', + textValue: t('Generate dashboard'), label: ( {t('Generate dashboard')} diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/groupByStep/groupBySelector.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/groupByStep/groupBySelector.tsx index 8660c0be4522..d397951c4d7d 100644 --- a/static/app/views/dashboards/widgetBuilder/buildSteps/groupByStep/groupBySelector.tsx +++ b/static/app/views/dashboards/widgetBuilder/buildSteps/groupByStep/groupBySelector.tsx @@ -1,6 +1,7 @@ import {Fragment, useMemo, useState, type ReactNode} from 'react'; import {closestCenter, DndContext, DragOverlay} from '@dnd-kit/core'; import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable'; +import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; @@ -27,6 +28,7 @@ import { type LinkedDashboard, type ValidateWidgetResponse, } from 'sentry/views/dashboards/types'; +import {correctDragOverlayOffset} from 'sentry/views/dashboards/widgetBuilder/components/common/draggableUtils'; import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; import {useDashboardWidgetSource} from 'sentry/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource'; import {useIsEditingWidget} from 'sentry/views/dashboards/widgetBuilder/hooks/useIsEditingWidget'; @@ -68,6 +70,7 @@ export function GroupBySelector({ const organization = useOrganization(); const source = useDashboardWidgetSource(); const isEditing = useIsEditingWidget(); + const theme = useTheme(); const builderVersion = WidgetBuilderVersion.SLIDEOUT; function handleAdd() { @@ -237,7 +240,11 @@ export function GroupBySelector({ ))} - + {activeId ? ( p.theme.space.xl}; width: 100%; button { cursor: grabbing; } - - @media (min-width: ${p => p.theme.breakpoints.sm}) { - width: 710px; - } `; diff --git a/static/app/views/dashboards/widgetBuilder/components/common/draggableUtils.tsx b/static/app/views/dashboards/widgetBuilder/components/common/draggableUtils.tsx index 48fbf43661ac..35d544ae65b6 100644 --- a/static/app/views/dashboards/widgetBuilder/components/common/draggableUtils.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/common/draggableUtils.tsx @@ -1,4 +1,4 @@ -import type {Translate} from '@dnd-kit/core'; +import type {Modifier, Translate} from '@dnd-kit/core'; export type WidgetDragPositioning = { initialTranslate: Translate; @@ -53,3 +53,26 @@ export function snapPreviewToCorners(over: any | null): WidgetDragPositioning { : undefined, }; } + +/** + * Corrects the DragOverlay ghost position when the DndContext is inside a CSS-transformed + * container (e.g. a SlideOverPanel animated with framer-motion). Without this, the ghost + * is offset by the container's distance from the viewport edge. + * + * Positions the ghost at the original element's viewport position, preserving the + * natural grab offset — the exact point clicked stays under the cursor. + */ +export const correctDragOverlayOffset: Modifier = ({ + activeNodeRect, + draggingNodeRect, + transform, +}) => { + if (!activeNodeRect || !draggingNodeRect) { + return transform; + } + return { + ...transform, + x: transform.x + activeNodeRect.left - draggingNodeRect.left, + y: transform.y + activeNodeRect.top - draggingNodeRect.top, + }; +}; diff --git a/static/app/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper.tsx b/static/app/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper.tsx index 57db386352a2..f42b5572be8e 100644 --- a/static/app/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper.tsx @@ -53,6 +53,7 @@ export function SortableVisualizeFieldWrapper({ const StyledDragReorderButton = styled(DragReorderButton)<{isDragging: boolean}>` height: ${p => p.theme.form.md.height}; + cursor: grab; ${p => p.isDragging && p.theme.visuallyHidden} `; diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx index af05cd4efc2b..df17a278d889 100644 --- a/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/visualize/index.tsx @@ -1,7 +1,7 @@ import {Fragment, useMemo, useState, type ReactNode} from 'react'; import {closestCenter, DndContext, DragOverlay} from '@dnd-kit/core'; import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable'; -import {css} from '@emotion/react'; +import {css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import cloneDeep from 'lodash/cloneDeep'; @@ -45,6 +45,7 @@ import { type LinkedDashboard, } from 'sentry/views/dashboards/types'; import {usesTimeSeriesData} from 'sentry/views/dashboards/utils'; +import {correctDragOverlayOffset} from 'sentry/views/dashboards/widgetBuilder/components/common/draggableUtils'; import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader'; import {SortableVisualizeFieldWrapper} from 'sentry/views/dashboards/widgetBuilder/components/common/sortableFieldWrapper'; import {ExploreArithmeticBuilder} from 'sentry/views/dashboards/widgetBuilder/components/exploreArithmeticBuilder'; @@ -279,6 +280,7 @@ interface VisualizeProps { export function Visualize({error, setError}: VisualizeProps) { const [activeId, setActiveId] = useState(null); const organization = useOrganization(); + const theme = useTheme(); const {state, dispatch} = useWidgetBuilderContext(); const tags = useTags(); const {customMeasurements} = useCustomMeasurements(); @@ -1063,7 +1065,11 @@ export function Visualize({error, setError}: VisualizeProps) { })} - + {activeId && ( p.theme.space.xl}; width: 100%; button { cursor: grabbing; } - - @media (min-width: ${p => p.theme.breakpoints.sm}) { - width: 710px; - } `; const StyledDragReorderButton = styled(DragReorderButton)` height: ${p => p.theme.form.md.height}; + cursor: grabbing; `; diff --git a/static/app/views/dashboards/widgetCard/visualizationWidget.tsx b/static/app/views/dashboards/widgetCard/visualizationWidget.tsx index d2abadf0cc43..ba83c983390c 100644 --- a/static/app/views/dashboards/widgetCard/visualizationWidget.tsx +++ b/static/app/views/dashboards/widgetCard/visualizationWidget.tsx @@ -48,6 +48,7 @@ import {Thresholds} from 'sentry/views/dashboards/widgets/timeSeriesWidget/plott import {TimeSeriesWidgetVisualization} from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; import {getExploreUrl} from 'sentry/views/explore/utils'; +import {NegativeCostWarning} from 'sentry/views/insights/common/components/tableCells/currencyCell'; import {TextAlignRight} from 'sentry/views/insights/common/components/textAlign'; import type {LoadableChartWidgetProps} from 'sentry/views/insights/common/components/widgets/types'; import {ModelName} from 'sentry/views/insights/pages/agents/components/modelName'; @@ -363,10 +364,14 @@ function VisualizationWidgetContent({ {labelContent} - {dataType === 'number' && - value !== null && - value > 0 && - value < NUMBER_MIN_VALUE ? ( + {dataType === 'currency' && value !== null && value < 0 ? ( + + {formatBreakdownLegendValue(value, dataType, dataUnit)} + + ) : dataType === 'number' && + value !== null && + value > 0 && + value < NUMBER_MIN_VALUE ? ( {formatBreakdownLegendValue(value, dataType, dataUnit)} diff --git a/static/app/views/detectors/components/connectAutomationsDrawer.tsx b/static/app/views/detectors/components/connectAutomationsDrawer.tsx index c315ba5641e7..ec23423099bb 100644 --- a/static/app/views/detectors/components/connectAutomationsDrawer.tsx +++ b/static/app/views/detectors/components/connectAutomationsDrawer.tsx @@ -2,7 +2,7 @@ import {Fragment, useCallback, useState} from 'react'; import styled from '@emotion/styled'; import {DrawerHeader} from 'sentry/components/globalDrawer/components'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t} from 'sentry/locale'; import type {Automation} from 'sentry/types/workflowEngine/automations'; import {getApiQueryData, setApiQueryData, useQueryClient} from 'sentry/utils/queryClient'; @@ -21,7 +21,7 @@ function ConnectedAutomations({ const [cursor, setCursor] = useState(undefined); return ( -
+ -
+ ); } @@ -51,7 +51,7 @@ function AllAutomations({ }, []); return ( -
+ -
+ ); } diff --git a/static/app/views/detectors/components/details/common/assignee.tsx b/static/app/views/detectors/components/details/common/assignee.tsx index 16a3b893ce36..813694a50156 100644 --- a/static/app/views/detectors/components/details/common/assignee.tsx +++ b/static/app/views/detectors/components/details/common/assignee.tsx @@ -3,7 +3,7 @@ import {Link} from '@sentry/scraps/link'; import {Tooltip} from '@sentry/scraps/tooltip'; import {Placeholder} from 'sentry/components/placeholder'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t} from 'sentry/locale'; import type {Detector} from 'sentry/types/workflowEngine/detectors'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -68,8 +68,8 @@ function DetectorOwner({owner}: {owner: Detector['owner']}) { export function DetectorDetailsAssignee({owner}: {owner: Detector['owner']}) { return ( -
+ -
+ ); } diff --git a/static/app/views/detectors/components/details/common/automations.tsx b/static/app/views/detectors/components/details/common/automations.tsx index ce25ad502b46..a5c364b4c463 100644 --- a/static/app/views/detectors/components/details/common/automations.tsx +++ b/static/app/views/detectors/components/details/common/automations.tsx @@ -15,7 +15,7 @@ import {Placeholder} from 'sentry/components/placeholder'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {ActionCell} from 'sentry/components/workflowEngine/gridCell/actionCell'; import {AutomationTitleCell} from 'sentry/components/workflowEngine/gridCell/automationTitleCell'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {IconAdd} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {Detector} from 'sentry/types/workflowEngine/detectors'; @@ -286,7 +286,7 @@ export function DetectorDetailsAutomations({detector}: Props) { ); return ( -
-
+ ); } diff --git a/static/app/views/detectors/components/details/common/description.tsx b/static/app/views/detectors/components/details/common/description.tsx index 1aeff3cfbbd4..d46faacf22e6 100644 --- a/static/app/views/detectors/components/details/common/description.tsx +++ b/static/app/views/detectors/components/details/common/description.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t} from 'sentry/locale'; import type {Detector} from 'sentry/types/workflowEngine/detectors'; import {MarkedText} from 'sentry/utils/marked/markedText'; @@ -14,9 +14,9 @@ export function DetectorDetailsDescription({ return null; } return ( -
+ -
+ ); } diff --git a/static/app/views/detectors/components/details/common/extraDetails.tsx b/static/app/views/detectors/components/details/common/extraDetails.tsx index df3f2a2160c1..16688e171e02 100644 --- a/static/app/views/detectors/components/details/common/extraDetails.tsx +++ b/static/app/views/detectors/components/details/common/extraDetails.tsx @@ -7,7 +7,7 @@ import {KeyValueTable, KeyValueTableRow} from 'sentry/components/keyValueTable'; import {Placeholder} from 'sentry/components/placeholder'; import {TextOverflow} from 'sentry/components/textOverflow'; import {TimeSince} from 'sentry/components/timeSince'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t} from 'sentry/locale'; import type {Detector} from 'sentry/types/workflowEngine/detectors'; import {useUserFromId} from 'sentry/utils/useUserFromId'; @@ -19,9 +19,9 @@ type Props = { export function DetectorExtraDetails({children}: Props) { return ( -
+ {children} -
+ ); } diff --git a/static/app/views/detectors/components/details/common/ongoingIssues.tsx b/static/app/views/detectors/components/details/common/ongoingIssues.tsx index 33b75319de9e..122946e71ab1 100644 --- a/static/app/views/detectors/components/details/common/ongoingIssues.tsx +++ b/static/app/views/detectors/components/details/common/ongoingIssues.tsx @@ -2,7 +2,7 @@ import {LinkButton} from '@sentry/scraps/button'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; import {GroupList} from 'sentry/components/issues/groupList'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; import type {Detector} from 'sentry/types/workflowEngine/detectors'; @@ -35,7 +35,7 @@ export function DetectorDetailsOngoingIssues({ }; return ( -
-
+ ); } diff --git a/static/app/views/detectors/components/details/common/openPeriodIssues.tsx b/static/app/views/detectors/components/details/common/openPeriodIssues.tsx index 11d22a2e8b37..88dc1a9c88b7 100644 --- a/static/app/views/detectors/components/details/common/openPeriodIssues.tsx +++ b/static/app/views/detectors/components/details/common/openPeriodIssues.tsx @@ -18,7 +18,7 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Placeholder} from 'sentry/components/placeholder'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {TimeAgoCell} from 'sentry/components/workflowEngine/gridCell/timeAgoCell'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t, tn} from 'sentry/locale'; import type {Group} from 'sentry/types/group'; import type {Detector} from 'sentry/types/workflowEngine/detectors'; @@ -268,7 +268,7 @@ export function DetectorDetailsOpenPeriodIssues({ }; return ( -
)} -
+ ); } diff --git a/static/app/views/detectors/components/details/cron/index.tsx b/static/app/views/detectors/components/details/cron/index.tsx index 48e0b653a9c9..a57f6bef4d04 100644 --- a/static/app/views/detectors/components/details/cron/index.tsx +++ b/static/app/views/detectors/components/details/cron/index.tsx @@ -17,7 +17,7 @@ import {PageFilterBar} from 'sentry/components/pageFilters/pageFilterBar'; import {TimeSince} from 'sentry/components/timeSince'; import {TimezoneProvider, useTimezone} from 'sentry/components/timezoneProvider'; import {DetailLayout} from 'sentry/components/workflowEngine/layout/detail'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {IconJson} from 'sentry/icons'; import {t, tn} from 'sentry/locale'; import type {Project} from 'sentry/types/project'; @@ -196,7 +196,7 @@ export function CronDetectorDetails({detector, project}: CronDetectorDetailsProp intervalSeconds={intervalSeconds} /> -
+
-
+ ) : ( @@ -215,22 +215,22 @@ export function CronDetectorDetails({detector, project}: CronDetectorDetailsProp )} -
+ {tn( 'One failed check-in.', '%s consecutive failed check-ins.', failure_issue_threshold ?? 1 )} -
-
+ + {tn( 'One successful check-in.', '%s consecutive successful check-ins.', recovery_threshold ?? 1 )} -
+ -
+
{scheduleAsText(dataSource.queryObj.config)}{' '} {dataSource.queryObj.config.schedule_type === ScheduleType.CRONTAB && @@ -241,14 +241,14 @@ export function CronDetectorDetails({detector, project}: CronDetectorDetailsProp )}
-
-
+ + -
+ + - + ); } const resolveAgeHours = detailedProject.resolveAge; return ( -
+

{formatResolveAge(resolveAgeHours)}

-
+ ); } @@ -96,7 +96,7 @@ export function ErrorDetectorDetails({detector, project}: ErrorDetectorDetailsPr -
+ {tct( 'All events have a fingerprint. Events with the same fingerprint are grouped together into an issue. To learn more about issue grouping, [link:read the docs].', @@ -107,8 +107,8 @@ export function ErrorDetectorDetails({detector, project}: ErrorDetectorDetailsPr } )} -
-
+ + {tct( 'Sentry will attempt to automatically assign new issues based on [link:Ownership Rules].', @@ -121,8 +121,8 @@ export function ErrorDetectorDetails({detector, project}: ErrorDetectorDetailsPr } )} -
-
+ + {tct( 'New error issues are prioritized based on log level. [link:Learn more about Issue Priority].', @@ -133,7 +133,7 @@ export function ErrorDetectorDetails({detector, project}: ErrorDetectorDetailsPr } )} -
+ diff --git a/static/app/views/detectors/components/details/metric/sidebar.tsx b/static/app/views/detectors/components/details/metric/sidebar.tsx index c5bbfea5880e..082d88dc4c57 100644 --- a/static/app/views/detectors/components/details/metric/sidebar.tsx +++ b/static/app/views/detectors/components/details/metric/sidebar.tsx @@ -4,7 +4,7 @@ import {Link} from 'react-router-dom'; import {Tooltip} from '@sentry/scraps/tooltip'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t} from 'sentry/locale'; import type {MetricDetector} from 'sentry/types/workflowEngine/detectors'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; @@ -44,11 +44,11 @@ function GoToMetricAlert({detector}: {detector: MetricDetector}) { export function MetricDetectorDetailsSidebar({detector}: DetectorDetailsSidebarProps) { return ( -
+ -
+ diff --git a/static/app/views/detectors/components/details/mobileBuild/sidebar.tsx b/static/app/views/detectors/components/details/mobileBuild/sidebar.tsx index e7cc965febc0..f8144943b178 100644 --- a/static/app/views/detectors/components/details/mobileBuild/sidebar.tsx +++ b/static/app/views/detectors/components/details/mobileBuild/sidebar.tsx @@ -1,7 +1,7 @@ import {Fragment} from 'react'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t} from 'sentry/locale'; import type {PreprodDetector} from 'sentry/types/workflowEngine/detectors'; import {DetectorDetailsAssignee} from 'sentry/views/detectors/components/details/common/assignee'; @@ -18,11 +18,11 @@ export function MobileBuildDetectorDetailsSidebar({ }: MobileBuildDetectorDetailsSidebarProps) { return ( -
+ -
+ diff --git a/static/app/views/detectors/components/details/uptime/index.tsx b/static/app/views/detectors/components/details/uptime/index.tsx index ad95ee67eabd..c6df5890b55a 100644 --- a/static/app/views/detectors/components/details/uptime/index.tsx +++ b/static/app/views/detectors/components/details/uptime/index.tsx @@ -8,7 +8,7 @@ import {KeyValueTableRow} from 'sentry/components/keyValueTable'; import {DatePageFilter} from 'sentry/components/pageFilters/date/datePageFilter'; import {Placeholder} from 'sentry/components/placeholder'; import {DetailLayout} from 'sentry/components/workflowEngine/layout/detail'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {DetailSection} from 'sentry/components/workflowEngine/ui/detailSection'; import {t, tn} from 'sentry/locale'; import type {Project} from 'sentry/types/project'; import type {UptimeDetector} from 'sentry/types/workflowEngine/detectors'; @@ -68,7 +68,7 @@ export function UptimeDetectorDetails({detector, project}: UptimeDetectorDetails /> -
+
-
+ -
+
{tn( '%s failed check.', @@ -91,19 +91,19 @@ export function UptimeDetectorDetails({detector, project}: UptimeDetectorDetails {`${dataSource.queryObj.method} ${dataSource.queryObj.url}`} -
-
+ + {tn( '%s successful check.', '%s consecutive successful checks.', detector.config.recoveryThreshold )} -
-
+ + -
+ -
+ {summary === undefined ? ( @@ -113,8 +113,8 @@ export function UptimeDetectorDetails({detector, project}: UptimeDetectorDetails ) : ( )} -
-
+ + {summary === undefined ? ( @@ -130,7 +130,7 @@ export function UptimeDetectorDetails({detector, project}: UptimeDetectorDetails )} /> )} -
+
diff --git a/static/app/views/detectors/components/forms/automateSection.tsx b/static/app/views/detectors/components/forms/automateSection.tsx index 14ac26fb7ea7..947d7160d3c7 100644 --- a/static/app/views/detectors/components/forms/automateSection.tsx +++ b/static/app/views/detectors/components/forms/automateSection.tsx @@ -9,7 +9,7 @@ import {FormContext} from 'sentry/components/forms/formContext'; import {useDrawer} from 'sentry/components/globalDrawer'; import {useFormField} from 'sentry/components/workflowEngine/form/useFormField'; import {Container} from 'sentry/components/workflowEngine/ui/container'; -import {Section} from 'sentry/components/workflowEngine/ui/section'; +import {FormSection} from 'sentry/components/workflowEngine/ui/formSection'; import {IconAdd, IconEdit} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {AutomationBuilderDrawerForm} from 'sentry/views/automations/components/automationBuilderDrawerForm'; @@ -77,7 +77,7 @@ export function AutomateSection() { if (workflowIds.length > 0) { return ( -
+ -
+