diff --git a/.github/workflows/scripts/deploy.js b/.github/workflows/scripts/deploy.js index cb4f00e3f864a3..979d53d5f4c4b2 100644 --- a/.github/workflows/scripts/deploy.js +++ b/.github/workflows/scripts/deploy.js @@ -1,5 +1,3 @@ -/* eslint-env node */ - /** * GHA Workflow helpers for deploys * diff --git a/.github/workflows/scripts/getsentry-dispatch.js b/.github/workflows/scripts/getsentry-dispatch.js index 37e46a91b971b5..f2f7ff71d0d1ed 100644 --- a/.github/workflows/scripts/getsentry-dispatch.js +++ b/.github/workflows/scripts/getsentry-dispatch.js @@ -1,5 +1,3 @@ -/* eslint-env node */ - /** * List of workflows to dispatch to `getsentry` * diff --git a/api-docs/index.ts b/api-docs/index.ts index 935294465dd919..161d4dd4b2ecbb 100644 --- a/api-docs/index.ts +++ b/api-docs/index.ts @@ -1,13 +1,10 @@ -/* global process */ -/* eslint-env node */ -/* eslint import/no-unresolved:0 */ import yaml from 'js-yaml'; import JsonRefs from 'json-refs'; import fs from 'node:fs'; import path from 'node:path'; function dictToString(dict) { - const res = []; + const res: string[] = []; for (const [k, v] of Object.entries(dict)) { res.push(`${k}: ${v}`); } @@ -15,6 +12,7 @@ function dictToString(dict) { } function bundle(originalFile) { + // @ts-expect-error: Types do not match the version of js-yaml installed const root = yaml.safeLoad(fs.readFileSync(originalFile, 'utf8')); const options = { filter: ['relative', 'remote', 'local'], @@ -22,6 +20,7 @@ function bundle(originalFile) { location: originalFile, loaderOptions: { processContent: function (res, callback) { + // @ts-expect-error: Types do not match the version of js-yaml installed callback(undefined, yaml.safeLoad(res.text)); }, }, diff --git a/api-docs/openapi-diff.ts b/api-docs/openapi-diff.ts index 4ea6b634c2245b..7b5a72659183c6 100644 --- a/api-docs/openapi-diff.ts +++ b/api-docs/openapi-diff.ts @@ -1,6 +1,3 @@ -/* eslint-env node */ -/* eslint import/no-unresolved:0 */ - import yaml from 'js-yaml'; import jsonDiff from 'json-diff'; import fs from 'node:fs'; @@ -26,6 +23,7 @@ async function main() { ); const readFile = fs.readFileSync('tests/apidocs/openapi-derefed.json', 'utf8'); + // @ts-expect-error: Types do not match the version of js-yaml installed const target = yaml.safeLoad(readFile); // eslint-disable-next-line no-console diff --git a/api-docs/watch.ts b/api-docs/watch.ts index 22612f8bee5e0b..69fac5f3ad3118 100644 --- a/api-docs/watch.ts +++ b/api-docs/watch.ts @@ -1,5 +1,3 @@ -/* eslint-env node */ -/* eslint import/no-unresolved:0, no-console:0 */ import {spawn} from 'node:child_process'; import {join} from 'node:path'; import {stderr, stdout} from 'node:process'; diff --git a/babel.config.ts b/babel.config.ts index 60efcd83e450a3..958fbae17ef014 100644 --- a/babel.config.ts +++ b/babel.config.ts @@ -1,5 +1,3 @@ -/* eslint-env node */ - import type {TransformOptions} from '@babel/core'; const config: TransformOptions = { diff --git a/build-utils/last-built-plugin.ts b/build-utils/last-built-plugin.ts index 00cf69e1b36e60..09d6ecc183dc10 100644 --- a/build-utils/last-built-plugin.ts +++ b/build-utils/last-built-plugin.ts @@ -1,5 +1,3 @@ -/* eslint-env node */ - import fs from 'node:fs'; import path from 'node:path'; import type webpack from 'webpack'; diff --git a/build-utils/sentry-instrumentation.ts b/build-utils/sentry-instrumentation.ts index ae502acd76f044..e1c9e6da83ffca 100644 --- a/build-utils/sentry-instrumentation.ts +++ b/build-utils/sentry-instrumentation.ts @@ -1,4 +1,3 @@ -/* eslint-env node */ import type {Span} from '@sentry/core'; import type * as Sentry from '@sentry/node'; import crypto from 'node:crypto'; diff --git a/config/webpack.chartcuterie.config.ts b/config/webpack.chartcuterie.config.ts index 095b2b7fef7207..104878a19a568a 100644 --- a/config/webpack.chartcuterie.config.ts +++ b/config/webpack.chartcuterie.config.ts @@ -1,5 +1,3 @@ -/* eslint-env node */ - import childProcess from 'node:child_process'; import path from 'node:path'; import webpack from 'webpack'; diff --git a/devenv/sync.py b/devenv/sync.py index e1550442fdba2a..51f8084148c4f4 100644 --- a/devenv/sync.py +++ b/devenv/sync.py @@ -253,8 +253,10 @@ def main(context: dict[str, str]) -> int: fs.ensure_symlink("../../config/hooks/post-merge", f"{reporoot}/.git/hooks/post-merge") - if not os.path.exists(f"{constants.home}/.sentry/config.yml") or not os.path.exists( - f"{constants.home}/.sentry/sentry.conf.py" + sentry_conf = os.environ.get("SENTRY_CONF", f"{constants.home}/.sentry") + + if not os.path.exists(f"{sentry_conf}/config.yml") or not os.path.exists( + f"{sentry_conf}/sentry.conf.py" ): proc.run((f"{venv_dir}/bin/sentry", "init", "--dev")) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1d8f3b8ca4506e..1fed90c61563b4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,7 @@ import simpleImportSort from 'eslint-plugin-simple-import-sort'; import testingLibrary from 'eslint-plugin-testing-library'; // @ts-expect-error TS (7016): Could not find a declaration file import typescriptSortKeys from 'eslint-plugin-typescript-sort-keys'; +import unicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; import invariant from 'invariant'; // biome-ignore lint/correctness/noNodejsModules: Need to get the list of things! @@ -236,6 +237,7 @@ export default typescript.config([ 'consistent-return': 'error', 'default-case': 'error', 'dot-notation': 'error', + eqeqeq: 'error', 'guard-for-in': 'off', // TODO(ryan953): Fix violations and enable this rule 'multiline-comment-style': ['error', 'separate-lines'], 'no-alert': 'error', @@ -268,6 +270,7 @@ export default typescript.config([ 'no-sequences': 'error', 'no-throw-literal': 'error', 'object-shorthand': ['error', 'properties'], + radix: 'error', 'require-await': 'error', // Enabled in favor of @typescript-eslint/require-await, which requires type info 'spaced-comment': [ 'error', @@ -277,10 +280,9 @@ export default typescript.config([ block: {exceptions: ['*'], balanced: true}, }, ], + strict: 'error', 'vars-on-top': 'off', 'wrap-iife': ['error', 'any'], - radix: 'error', - strict: 'error', yoda: 'error', // https://github.com/eslint/eslint/blob/main/packages/js/src/configs/eslint-recommended.js @@ -306,6 +308,7 @@ export default typescript.config([ 'import/no-anonymous-default-export': 'error', 'import/no-duplicates': 'error', 'import/no-named-default': 'error', + 'import/no-nodejs-modules': 'error', 'import/no-webpack-loader-syntax': 'error', // https://github.com/import-js/eslint-plugin-import/blob/main/config/recommended.js @@ -369,6 +372,7 @@ export default typescript.config([ {selector: 'typeLike', format: ['PascalCase'], leadingUnderscore: 'allow'}, {selector: 'enumMember', format: ['UPPER_CASE']}, ], + '@typescript-eslint/no-restricted-types': [ 'error', { @@ -392,6 +396,7 @@ export default typescript.config([ ], '@typescript-eslint/no-shadow': 'error', '@typescript-eslint/no-use-before-define': 'off', // Enabling this will cause a lot of thrash to the git history + '@typescript-eslint/no-useless-empty-export': 'error', }, }, // https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/configs/base.ts @@ -406,8 +411,8 @@ export default typescript.config([ // https://typescript-eslint.io/rules/ plugins: {'@typescript-eslint': typescript.plugin}, rules: { - 'no-var': 'off', // TODO(ryan953): Fix violations and delete this line 'prefer-spread': 'off', // TODO(ryan953): Fix violations and delete this line + '@typescript-eslint/prefer-enum-initializers': 'error', // Recommended overrides '@typescript-eslint/ban-ts-comment': 'off', // TODO(ryan953): Fix violations and delete this line @@ -427,7 +432,6 @@ export default typescript.config([ '@typescript-eslint/no-extraneous-class': 'off', // TODO(ryan953): Fix violations and delete this line '@typescript-eslint/no-invalid-void-type': 'off', // TODO(ryan953): Fix violations and delete this line '@typescript-eslint/no-non-null-assertion': 'off', // TODO(ryan953): Fix violations and delete this line - '@typescript-eslint/prefer-literal-enum-member': 'off', // TODO(ryan953): Fix violations and delete this line '@typescript-eslint/unified-signatures': 'off', // TODO(ryan953): Fix violations and delete this line // Stylistic overrides @@ -439,7 +443,6 @@ export default typescript.config([ '@typescript-eslint/no-empty-function': 'off', // TODO(ryan953): Fix violations and delete this line '@typescript-eslint/no-inferrable-types': 'off', // TODO(ryan953): Fix violations and delete this line '@typescript-eslint/prefer-for-of': 'off', // TODO(ryan953): Fix violations and delete this line - '@typescript-eslint/prefer-function-type': 'off', // TODO(ryan953): Fix violations and delete this line // Customization '@typescript-eslint/no-unused-vars': [ @@ -495,13 +498,13 @@ export default typescript.config([ { groups: [ // Side effect imports. - ['^\\u0000'], + [String.raw`^\u0000`], // Node.js builtins. [`^(${builtinModules.join('|')})(/|$)`], // Packages. `react` related packages come first. - ['^react', '^@?\\w'], + ['^react', String.raw`^@?\w`], // Test should be separate from the app ['^(sentry-test|getsentry-test)(/.*|$)'], @@ -517,13 +520,13 @@ export default typescript.config([ ['^(admin|getsentry)(/.*|$)'], // Style imports. - ['^.+\\.less$'], + [String.raw`^.+\.less$`], // Parent imports. Put `..` last. - ['^\\.\\.(?!/?$)', '^\\.\\./?$'], + [String.raw`^\.\.(?!/?$)`, String.raw`^\.\./?$`], // Other relative imports. Put same-folder imports and `.` last. - ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], + [String.raw`^\./(?=.*/)(?!/?$)`, String.raw`^\.(?!/?$)`, String.raw`^\./?$`], ], }, ], @@ -552,32 +555,35 @@ export default typescript.config([ '@emotion/syntax-preference': ['error', 'string'], }, }, + { + name: 'plugin/unicorn', + plugins: {unicorn}, + rules: { + // The recommended rules are very opinionated. We don't need to enable them. + + 'unicorn/no-instanceof-array': 'error', + 'unicorn/prefer-array-flat-map': 'error', + 'unicorn/prefer-node-protocol': 'error', + }, + }, { name: 'plugin/jest', files: ['**/*.spec.{ts,js,tsx,jsx}', 'tests/js/**/*.{ts,js,tsx,jsx}'], // https://github.com/jest-community/eslint-plugin-jest/tree/main/docs/rules plugins: jest.configs['flat/recommended'].plugins, rules: { + 'jest/max-nested-describe': 'error', + 'jest/no-duplicate-hooks': 'error', + 'jest/no-large-snapshots': ['error', {maxSize: 2000}], // We don't recommend snapshots, but if there are any keep it small + // https://github.com/jest-community/eslint-plugin-jest/blob/main/src/index.ts ...jest.configs['flat/recommended'].rules, ...jest.configs['flat/style'].rules, - // `recommended` set this to warn, we've upgraded to error - 'jest/no-disabled-tests': 'error', - - // `recommended` set this to warn, we've downgraded to off - // Disabled as we have many tests which render as simple validations - 'jest/expect-expect': 'off', - - // Disabled as we have some comment out tests that cannot be - // uncommented due to typescript errors. + 'jest/expect-expect': 'off', // Disabled as we have many tests which render as simple validations 'jest/no-commented-out-tests': 'off', // TODO(ryan953): Fix violations then delete this line - - // Disabled as we do sometimes have conditional expects 'jest/no-conditional-expect': 'off', // TODO(ryan953): Fix violations then delete this line - - // We don't recommend snapshots, but if there are any keep it small - 'jest/no-large-snapshots': ['error', {maxSize: 2000}], + 'jest/no-disabled-tests': 'error', // `recommended` set this to warn, we've upgraded to error }, }, { @@ -604,13 +610,17 @@ export default typescript.config([ }, { name: 'files/*.config.*', - files: ['*.config.*'], + files: ['**/*.config.*'], languageOptions: { globals: { ...globals.commonjs, ...globals.node, }, }, + + rules: { + 'import/no-nodejs-modules': 'off', + }, }, { name: 'files/scripts', @@ -624,6 +634,8 @@ export default typescript.config([ }, rules: { 'no-console': 'off', + + 'import/no-nodejs-modules': 'off', }, }, { @@ -632,7 +644,9 @@ export default typescript.config([ 'tests/js/jest-pegjs-transform.js', 'tests/js/sentry-test/echartsMock.js', 'tests/js/sentry-test/importStyleMock.js', + 'tests/js/sentry-test/loadFixtures.ts', 'tests/js/sentry-test/svgMock.js', + 'tests/js/setup.ts', ], languageOptions: { sourceType: 'commonjs', @@ -640,7 +654,9 @@ export default typescript.config([ ...globals.commonjs, }, }, - rules: {}, + rules: { + 'import/no-nodejs-modules': 'off', + }, }, { name: 'files/devtoolbar', diff --git a/jest.config.ts b/jest.config.ts index fa2eefb60a450e..67946c1aebdb11 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,4 +1,3 @@ -/* eslint-env node */ import type {Config} from '@jest/types'; import path from 'node:path'; import process from 'node:process'; diff --git a/migrations_lockfile.txt b/migrations_lockfile.txt index d30a4ce2837287..a4730ef6421d3d 100644 --- a/migrations_lockfile.txt +++ b/migrations_lockfile.txt @@ -21,6 +21,6 @@ social_auth: 0002_default_auto_field tempest: 0001_create_tempest_credentials_model -uptime: 0021_drop_region_table_col +uptime: 0022_add_trace_sampling_to_uptime_monitors workflow_engine: 0023_create_action_trigger_action_table diff --git a/package.json b/package.json index edbda0fe68329b..f741f532b730df 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@sentry/node": "8.48.0", "@sentry/react": "8.48.0", "@sentry/release-parser": "^1.3.1", - "@sentry/status-page-list": "^0.3.0", + "@sentry/status-page-list": "^0.6.0", "@sentry/types": "8.48.0", "@sentry/utils": "8.48.0", "@sentry/webpack-plugin": "^2.22.4", @@ -146,7 +146,6 @@ "qrcode.react": "^3.1.0", "query-string": "7.0.1", "react": "18.2.0", - "react-textarea-autosize": "8.5.7", "react-date-range": "^1.4.0", "react-dom": "18.2.0", "react-grid-layout": "^1.3.4", @@ -156,6 +155,7 @@ "react-router-dom": "^6.26.2", "react-select": "4.3.1", "react-sparklines": "1.7.0", + "react-textarea-autosize": "8.5.7", "react-virtualized": "^9.22.5", "reflux": "0.4.1", "screenfull": "^6.0.2", @@ -205,6 +205,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-testing-library": "^7.1.1", "eslint-plugin-typescript-sort-keys": "^3.3.0", + "eslint-plugin-unicorn": "^56.0.1", "globals": "^15.14.0", "html-webpack-plugin": "^5.6.0", "jest": "29.7.0", @@ -213,7 +214,7 @@ "jest-fail-on-console": "3.3.0", "jest-junit": "16.0.0", "postcss-styled-syntax": "0.7.0", - "react-refresh": "0.14.0", + "react-refresh": "0.16.0", "stylelint": "16.10.0", "stylelint-config-recommended": "^14.0.1", "terser": "5.31.6", diff --git a/pyproject.toml b/pyproject.toml index 146a99a4daeeeb..95e441951d10ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,7 +152,6 @@ module = [ "sentry.api.permissions", "sentry.api.serializers.models.auth_provider", "sentry.api.serializers.models.event", - "sentry.api.serializers.models.group_stream", "sentry.api.serializers.models.role", "sentry.auth.helper", "sentry.auth.provider", @@ -208,9 +207,6 @@ module = [ "sentry.integrations.slack.integration", "sentry.integrations.slack.message_builder.notifications.issues", "sentry.integrations.slack.notifications", - "sentry.integrations.slack.unfurl.discover", - "sentry.integrations.slack.utils.channel", - "sentry.integrations.slack.utils.users", "sentry.integrations.slack.webhooks.command", "sentry.integrations.slack.webhooks.event", "sentry.integrations.utils.commit_context", @@ -302,7 +298,6 @@ disable_error_code = [ "override", "return-value", "typeddict-item", - "typeddict-unknown-key", "union-attr", "unreachable", "var-annotated", diff --git a/requirements-base.txt b/requirements-base.txt index 85410f7218c318..d4f0991d281b4f 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -70,7 +70,7 @@ sentry-kafka-schemas>=0.1.128 sentry-ophio==1.0.0 sentry-protos>=0.1.51 sentry-redis-tools>=0.1.7 -sentry-relay>=0.9.4 +sentry-relay>=0.9.5 sentry-sdk[http2]>=2.19.2 slack-sdk>=3.27.2 snuba-sdk>=3.0.43 diff --git a/requirements-dev-frozen.txt b/requirements-dev-frozen.txt index a4cc3c8d50eb5d..1309394daea490 100644 --- a/requirements-dev-frozen.txt +++ b/requirements-dev-frozen.txt @@ -37,7 +37,7 @@ cryptography==43.0.1 cssselect==1.0.3 cssutils==2.9.0 datadog==0.49.1 -devservices==1.0.9 +devservices==1.0.10 distlib==0.3.8 distro==1.8.0 django==5.1.5 @@ -192,7 +192,7 @@ sentry-kafka-schemas==0.1.128 sentry-ophio==1.0.0 sentry-protos==0.1.51 sentry-redis-tools==0.1.7 -sentry-relay==0.9.4 +sentry-relay==0.9.5 sentry-sdk==2.19.2 sentry-usage-accountant==0.0.10 simplejson==3.17.6 @@ -230,7 +230,7 @@ types-pytz==2022.1.2 types-pyyaml==6.0.11 types-redis==3.5.18 types-requests==2.32.0.20241016 -types-requests-oauthlib==2.0.0.20240417 +types-requests-oauthlib==2.0.0.20250119 types-setuptools==69.0.0.0 types-simplejson==3.17.7.2 types-unidiff==0.7.0.20240505 diff --git a/requirements-dev.txt b/requirements-dev.txt index df0a6744b7b705..d57e7c957f4541 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ --index-url https://pypi.devinfra.sentry.io/simple sentry-devenv>=1.14.2 -devservices>=1.0.9 +devservices>=1.0.10 covdefaults>=2.3.0 sentry-covdefaults-disable-branch-coverage>=1.0.2 @@ -58,7 +58,7 @@ types-pyyaml # make sure to match close-enough to redis== types-redis<4 types-requests>=2.32.0.20241016 -types-requests-oauthlib +types-requests-oauthlib>=2.0.0.20250119 types-setuptools>=68 types-simplejson>=3.17.7.2 types-unidiff diff --git a/requirements-frozen.txt b/requirements-frozen.txt index a8a7f0c0f6568c..111993b3a08259 100644 --- a/requirements-frozen.txt +++ b/requirements-frozen.txt @@ -130,7 +130,7 @@ sentry-kafka-schemas==0.1.128 sentry-ophio==1.0.0 sentry-protos==0.1.51 sentry-redis-tools==0.1.7 -sentry-relay==0.9.4 +sentry-relay==0.9.5 sentry-sdk==2.19.2 sentry-usage-accountant==0.0.10 simplejson==3.17.6 diff --git a/src/sentry/api/endpoints/group_ai_autofix.py b/src/sentry/api/endpoints/group_ai_autofix.py index 50a2ea2c0e057d..577153acf0da4a 100644 --- a/src/sentry/api/endpoints/group_ai_autofix.py +++ b/src/sentry/api/endpoints/group_ai_autofix.py @@ -22,7 +22,7 @@ from sentry.models.group import Group from sentry.models.project import Project from sentry.profiles.utils import get_from_profiling_service -from sentry.seer.signed_seer_api import get_seer_salted_url, sign_with_seer_secret +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.snuba.dataset import Dataset from sentry.snuba.referrer import Referrer from sentry.tasks.autofix import check_autofix_status @@ -119,10 +119,14 @@ def _get_profile_for_event( if response.status == 200: profile = orjson.loads(response.data) execution_tree = self._convert_profile_to_execution_tree(profile) - output = { - "profile_matches_issue": profile_matches_event, - "execution_tree": execution_tree, - } + output = ( + None + if not execution_tree + else { + "profile_matches_issue": profile_matches_event, + "execution_tree": execution_tree, + } + ) return output else: return None @@ -132,15 +136,21 @@ def _convert_profile_to_execution_tree(self, profile_data: dict) -> list[dict]: Converts profile data into a hierarchical representation of code execution, including only items from the MainThread and app frames. """ - profile = profile_data["profile"] - frames = profile["frames"] - stacks = profile["stacks"] - samples = profile["samples"] + profile = profile_data.get("profile") + if not profile: + return [] + + frames = profile.get("frames") + stacks = profile.get("stacks") + samples = profile.get("samples") - thread_metadata = profile.get("thread_metadata", {}) + if not all([frames, stacks, samples]): + return [] + + thread_metadata = profile.get("thread_metadata") or {} main_thread_id = None for key, value in thread_metadata.items(): - if value["name"] == "MainThread": + if value.get("name") == "MainThread": main_thread_id = key break @@ -220,7 +230,7 @@ def process_stack(stack_index: int) -> list[dict]: stack_id = sample["stack_id"] thread_id = sample["thread_id"] - if str(thread_id) != str(main_thread_id): + if not main_thread_id or str(thread_id) != str(main_thread_id): continue stack_frames = process_stack(stack_id) @@ -289,16 +299,12 @@ def _call_autofix( option=orjson.OPT_NON_STR_KEYS, ) - url, salt = get_seer_salted_url(f"{settings.SEER_AUTOFIX_URL}{path}") response = requests.post( - url, + f"{settings.SEER_AUTOFIX_URL}{path}", data=body, headers={ "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret( - salt, - body=body, - ), + **sign_with_seer_secret(body), }, ) diff --git a/src/sentry/api/endpoints/group_ai_summary.py b/src/sentry/api/endpoints/group_ai_summary.py index 2450597a93513c..a1996c278204e4 100644 --- a/src/sentry/api/endpoints/group_ai_summary.py +++ b/src/sentry/api/endpoints/group_ai_summary.py @@ -22,7 +22,7 @@ from sentry.eventstore.models import Event, GroupEvent from sentry.models.group import Group from sentry.models.project import Project -from sentry.seer.signed_seer_api import get_seer_salted_url, sign_with_seer_secret +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.types.ratelimit import RateLimit, RateLimitCategory from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser @@ -129,16 +129,12 @@ def _call_seer( option=orjson.OPT_NON_STR_KEYS, ) - url, salt = get_seer_salted_url(f"{settings.SEER_AUTOFIX_URL}{path}") response = requests.post( - url, + f"{settings.SEER_AUTOFIX_URL}{path}", data=body, headers={ "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret( - salt, - body=body, - ), + **sign_with_seer_secret(body), }, ) diff --git a/src/sentry/api/endpoints/group_autofix_setup_check.py b/src/sentry/api/endpoints/group_autofix_setup_check.py index 70ead283887a20..da47535b002e12 100644 --- a/src/sentry/api/endpoints/group_autofix_setup_check.py +++ b/src/sentry/api/endpoints/group_autofix_setup_check.py @@ -18,7 +18,7 @@ from sentry.models.group import Group from sentry.models.organization import Organization from sentry.models.project import Project -from sentry.seer.signed_seer_api import get_seer_salted_url, sign_with_seer_secret +from sentry.seer.signed_seer_api import sign_with_seer_secret logger = logging.getLogger(__name__) @@ -77,16 +77,12 @@ def get_repos_and_access(project: Project) -> list[dict]: } ) - url, salt = get_seer_salted_url(f"{settings.SEER_AUTOFIX_URL}{path}") response = requests.post( - url, + f"{settings.SEER_AUTOFIX_URL}{path}", data=body, headers={ "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret( - salt, - body=body, - ), + **sign_with_seer_secret(body), }, ) diff --git a/src/sentry/api/endpoints/group_autofix_update.py b/src/sentry/api/endpoints/group_autofix_update.py index 60906134faad69..ce3d5d2f7b2d97 100644 --- a/src/sentry/api/endpoints/group_autofix_update.py +++ b/src/sentry/api/endpoints/group_autofix_update.py @@ -13,7 +13,7 @@ from sentry.api.base import region_silo_endpoint from sentry.api.bases.group import GroupEndpoint from sentry.models.group import Group -from sentry.seer.signed_seer_api import get_seer_salted_url, sign_with_seer_secret +from sentry.seer.signed_seer_api import sign_with_seer_secret logger = logging.getLogger(__name__) @@ -55,16 +55,12 @@ def post(self, request: Request, group: Group) -> Response: } ) - url, salt = get_seer_salted_url(f"{settings.SEER_AUTOFIX_URL}{path}") response = requests.post( - url, + f"{settings.SEER_AUTOFIX_URL}{path}", data=body, headers={ "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret( - salt, - body=body, - ), + **sign_with_seer_secret(body), }, ) diff --git a/src/sentry/api/endpoints/organization_events_stats.py b/src/sentry/api/endpoints/organization_events_stats.py index 5bc1feb81a707e..2d240e2f24536a 100644 --- a/src/sentry/api/endpoints/organization_events_stats.py +++ b/src/sentry/api/endpoints/organization_events_stats.py @@ -278,6 +278,7 @@ def get(self, request: Request, organization: Organization) -> Response: force_metrics_layer = request.GET.get("forceMetricsLayer") == "true" use_rpc = request.GET.get("useRpc", "0") == "1" + sentry_sdk.set_tag("performance.use_rpc", use_rpc) def _get_event_stats( scoped_dataset: Any, diff --git a/src/sentry/api/endpoints/organization_spans_fields.py b/src/sentry/api/endpoints/organization_spans_fields.py index 781dd063687de4..38c15878e545e3 100644 --- a/src/sentry/api/endpoints/organization_spans_fields.py +++ b/src/sentry/api/endpoints/organization_spans_fields.py @@ -27,8 +27,8 @@ from sentry.api.utils import handle_query_errors from sentry.models.organization import Organization from sentry.search.eap import constants -from sentry.search.eap.columns import translate_internal_to_public_alias -from sentry.search.eap.spans import SearchResolver +from sentry.search.eap.resolver import SearchResolver +from sentry.search.eap.span_columns import SPAN_DEFINITIONS, translate_internal_to_public_alias from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.builder.base import BaseQueryBuilder from sentry.search.events.builder.spans_indexed import SpansIndexedQueryBuilder @@ -110,7 +110,9 @@ def get(self, request: Request, organization: Organization) -> Response: hour=0, minute=0, second=0, microsecond=0 ) + timedelta(days=1) - resolver = SearchResolver(params=snuba_params, config=SearchResolverConfig()) + resolver = SearchResolver( + params=snuba_params, config=SearchResolverConfig(), definitions=SPAN_DEFINITIONS + ) meta = resolver.resolve_meta(referrer=Referrer.API_SPANS_TAG_KEYS_RPC.value) rpc_request = TraceItemAttributeNamesRequest( @@ -406,7 +408,9 @@ def __init__( max_span_tag_values: int, ): super().__init__(organization, snuba_params, key, query, max_span_tag_values) - self.resolver = SearchResolver(params=snuba_params, config=SearchResolverConfig()) + self.resolver = SearchResolver( + params=snuba_params, config=SearchResolverConfig(), definitions=SPAN_DEFINITIONS + ) self.search_type, self.attribute_key = self.resolve_attribute_key(key, snuba_params) def resolve_attribute_key( diff --git a/src/sentry/api/endpoints/project_details.py b/src/sentry/api/endpoints/project_details.py index 66ae7041f1b147..2e51e5c857cb16 100644 --- a/src/sentry/api/endpoints/project_details.py +++ b/src/sentry/api/endpoints/project_details.py @@ -697,7 +697,7 @@ def put(self, request: Request, project) -> Response: if result.get("highlightTags") is not None: if project.update_option("sentry:highlight_tags", result["highlightTags"]): changed_proj_settings["sentry:highlight_tags"] = result["highlightTags"] - if result.get("storeCrashReports") is not None: + if "storeCrashReports" in result: if project.get_option("sentry:store_crash_reports") != result["storeCrashReports"]: changed_proj_settings["sentry:store_crash_reports"] = result["storeCrashReports"] if result["storeCrashReports"] is None: diff --git a/src/sentry/api/endpoints/project_platforms.py b/src/sentry/api/endpoints/project_platforms.py deleted file mode 100644 index ba9efdd625b7ff..00000000000000 --- a/src/sentry/api/endpoints/project_platforms.py +++ /dev/null @@ -1,21 +0,0 @@ -from rest_framework.request import Request -from rest_framework.response import Response - -from sentry.api.api_owners import ApiOwner -from sentry.api.api_publish_status import ApiPublishStatus -from sentry.api.base import region_silo_endpoint -from sentry.api.bases.project import ProjectEndpoint -from sentry.api.serializers import serialize -from sentry.models.projectplatform import ProjectPlatform - - -@region_silo_endpoint -class ProjectPlatformsEndpoint(ProjectEndpoint): - publish_status = { - "GET": ApiPublishStatus.PRIVATE, - } - owner = ApiOwner.TELEMETRY_EXPERIENCE - - def get(self, request: Request, project) -> Response: - queryset = ProjectPlatform.objects.filter(project_id=project.id) - return Response(serialize(list(queryset), request.user)) diff --git a/src/sentry/api/serializers/models/group.py b/src/sentry/api/serializers/models/group.py index 260bb1b5c32d95..efcb214b1a91e8 100644 --- a/src/sentry/api/serializers/models/group.py +++ b/src/sentry/api/serializers/models/group.py @@ -149,7 +149,7 @@ class SeenStats(TypedDict): user_count: int -def _is_seen_stats(o: object) -> TypeGuard[SeenStats]: +def is_seen_stats(o: object) -> TypeGuard[SeenStats]: # not a perfect check, but simulates what was being validated before return isinstance(o, dict) and "times_seen" in o @@ -361,7 +361,7 @@ def serialize( # This attribute is currently feature gated if "is_unhandled" in attrs: group_dict["isUnhandled"] = attrs["is_unhandled"] - if _is_seen_stats(attrs): + if is_seen_stats(attrs): group_dict.update(self._convert_seen_stats(attrs)) return group_dict diff --git a/src/sentry/api/serializers/models/group_stream.py b/src/sentry/api/serializers/models/group_stream.py index 399649e2095319..d0346931f38973 100644 --- a/src/sentry/api/serializers/models/group_stream.py +++ b/src/sentry/api/serializers/models/group_stream.py @@ -2,20 +2,24 @@ import functools from abc import abstractmethod -from collections.abc import Mapping, MutableMapping, Sequence -from dataclasses import dataclass +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import Any, Protocol +from typing import Any, NamedTuple, NotRequired, Protocol, TypedDict +from django.contrib.auth.models import AnonymousUser from django.utils import timezone from sentry import features, release_health, tsdb from sentry.api.serializers import serialize from sentry.api.serializers.models.group import ( BaseGroupSerializerResponse, + GroupAnnotation, + GroupProjectResponse, GroupSerializer, GroupSerializerSnuba, + GroupStatusDetailsResponseOptional, SeenStats, + is_seen_stats, snuba_tsdb, ) from sentry.api.serializers.models.plugin import is_plugin_deprecated @@ -26,15 +30,19 @@ from sentry.models.environment import Environment from sentry.models.eventattachment import EventAttachment from sentry.models.group import Group -from sentry.models.groupinbox import get_inbox_details +from sentry.models.groupinbox import InboxDetails, get_inbox_details from sentry.models.grouplink import GroupLink -from sentry.models.groupowner import get_owner_details +from sentry.models.groupowner import OwnersSerialized, get_owner_details +from sentry.notifications.helpers import SubscriptionDetails from sentry.sentry_apps.api.serializers.platform_external_issue import ( PlatformExternalIssueSerializer, ) from sentry.sentry_apps.models.platformexternalissue import PlatformExternalIssue from sentry.snuba.dataset import Dataset from sentry.tsdb.base import TSDBModel +from sentry.users.api.serializers.user import UserSerializerResponse +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser from sentry.utils import metrics from sentry.utils.cache import cache from sentry.utils.hashlib import hash_values @@ -42,7 +50,7 @@ from sentry.utils.snuba import resolve_column, resolve_conditions -def get_actions(group): +def get_actions(group: Group) -> list[tuple[str, str]]: from sentry.plugins.base import plugins project = group.project @@ -77,11 +85,10 @@ def get_available_issue_plugins(group) -> list[dict[str, Any]]: return plugin_issues -@dataclass -class GroupStatsQueryArgs: +class GroupStatsQueryArgs(NamedTuple): stats_period: str | None - stats_period_start: datetime | None - stats_period_end: datetime | None + stats_period_start: datetime | None = None + stats_period_end: datetime | None = None class GroupStatsMixin: @@ -104,7 +111,13 @@ class GroupStatsMixin: @abstractmethod def query_tsdb( - self, groups: Sequence[Group], query_params: MutableMapping[str, Any], user=None + self, + groups: Sequence[Group], + query_params, + conditions=None, + environment_ids=None, + user=None, + **kwargs, ): pass @@ -114,6 +127,8 @@ def get_stats( if stats_query_args and stats_query_args.stats_period: # we need to compute stats at 1d (1h resolution), and 14d or a custom given period if stats_query_args.stats_period == "auto": + assert stats_query_args.stats_period_end is not None + assert stats_query_args.stats_period_start is not None total_period = ( stats_query_args.stats_period_end - stats_query_args.stats_period_start ).total_seconds() @@ -154,65 +169,80 @@ def get_stats( return self.query_tsdb(item_list, query_params, user=user, **kwargs) +class _MaybeStats(TypedDict, total=False): + stats: dict[str, dict[int, list[tuple[int, int]]]] + + +class StreamGroupSerializerResponse(BaseGroupSerializerResponse, _MaybeStats): + pass + + class StreamGroupSerializer(GroupSerializer, GroupStatsMixin): - def __init__( - self, - environment_func=None, - stats_period=None, - stats_period_start=None, - stats_period_end=None, - ): + def __init__(self, environment_func=None, stats_period=None): super().__init__(environment_func=environment_func) - if stats_period is not None: - assert stats_period in self.STATS_PERIOD_CHOICES or stats_period == "auto" + assert ( + stats_period is None + or stats_period in self.STATS_PERIOD_CHOICES + or stats_period == "auto" + ), stats_period self.stats_period = stats_period - self.stats_period_start = stats_period_start - self.stats_period_end = stats_period_end def get_attrs( self, item_list: Sequence[Group], - user: Any, + user: User | RpcUser | AnonymousUser, **kwargs: Any, - ) -> MutableMapping[Group, MutableMapping[str, Any]]: + ) -> dict[Group, dict[str, Any]]: attrs = super().get_attrs(item_list, user) if self.stats_period: stats = self.get_stats( item_list, user, - GroupStatsQueryArgs( - self.stats_period, self.stats_period_start, self.stats_period_end - ), + GroupStatsQueryArgs(self.stats_period, None, None), ) for item in item_list: - attrs[item].update({"stats": stats[item.id]}) + attrs[item]["stats"] = stats[item.id] return attrs def serialize( - self, obj: Group, attrs: MutableMapping[str, Any], user: Any, **kwargs: Any - ) -> BaseGroupSerializerResponse: - result = super().serialize(obj, attrs, user) + self, + obj: Group, + attrs: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> StreamGroupSerializerResponse: + base = super().serialize(obj, attrs, user) if self.stats_period: - result["stats"] = {self.stats_period: attrs["stats"]} + extra: _MaybeStats = {"stats": {self.stats_period: attrs["stats"]}} + else: + extra = {} - return result + return {**base, **extra} - def query_tsdb(self, groups: Sequence[Group], query_params, user=None, **kwargs): + def query_tsdb( + self, + groups: Sequence[Group], + query_params, + conditions=None, + environment_ids=None, + user=None, + **kwargs, + ): try: environment = self.environment_func() except Environment.DoesNotExist: - stats = {g.id: tsdb.make_series(0, **query_params) for g in groups} + stats = {g.id: tsdb.backend.make_series(0, **query_params) for g in groups} else: org_id = groups[0].project.organization_id if groups else None - stats = tsdb.get_range( + stats = tsdb.backend.get_range( model=TSDBModel.group, keys=[g.id for g in groups], - environment_ids=environment and [environment.id], + environment_ids=[environment.id] if environment is not None else None, **query_params, tenant_ids={"organization_id": org_id} if org_id else None, ) @@ -231,6 +261,56 @@ def __call__( ) -> Mapping[str, Any]: ... +class StreamGroupSerializerSnubaResponse(TypedDict): + id: str + # from base response + shareId: NotRequired[str] + shortId: NotRequired[str] + title: NotRequired[str] + culprit: NotRequired[str | None] + permalink: NotRequired[str] + logger: NotRequired[str | None] + level: NotRequired[str] + status: NotRequired[str] + statusDetails: NotRequired[GroupStatusDetailsResponseOptional] + substatus: NotRequired[str | None] + isPublic: NotRequired[bool] + platform: NotRequired[str | None] + priority: NotRequired[str | None] + priorityLockedAt: NotRequired[datetime | None] + project: NotRequired[GroupProjectResponse] + type: NotRequired[str] + issueType: NotRequired[str] + issueCategory: NotRequired[str] + metadata: NotRequired[Mapping[str, Any]] + numComments: NotRequired[int] + assignedTo: NotRequired[UserSerializerResponse] + isBookmarked: NotRequired[bool] + isSubscribed: NotRequired[bool] + subscriptionDetails: NotRequired[SubscriptionDetails | None] + hasSeen: NotRequired[bool] + annotations: NotRequired[Sequence[GroupAnnotation]] + # from base response optional + isUnhandled: NotRequired[bool] + count: NotRequired[int] + userCount: NotRequired[int] + firstSeen: NotRequired[datetime] + lastSeen: NotRequired[datetime] + + # from the serializer itself + stats: NotRequired[dict[str, Any]] + lifetime: NotRequired[dict[str, Any]] + filtered: NotRequired[dict[str, Any] | None] + sessionCount: NotRequired[int] + inbox: NotRequired[InboxDetails] + owners: NotRequired[OwnersSerialized] + pluginActions: NotRequired[list[tuple[str, str]]] + pluginIssues: NotRequired[list[dict[str, Any]]] + integrationIssues: NotRequired[list[dict[str, Any]]] + sentryAppIssues: NotRequired[list[dict[str, Any]]] + latestEventHasAttachments: NotRequired[bool] + + class StreamGroupSerializerSnuba(GroupSerializerSnuba, GroupStatsMixin): def __init__( self, @@ -269,18 +349,20 @@ def __init__( def get_attrs( self, item_list: Sequence[Group], - user: Any, + user: User | RpcUser | AnonymousUser, **kwargs: Any, - ) -> MutableMapping[Group, MutableMapping[str, Any]]: + ) -> dict[Group, dict[str, Any]]: if not self._collapse("base"): attrs = super().get_attrs(item_list, user) else: seen_stats = self._get_seen_stats(item_list, user) - if seen_stats: - attrs = {item: seen_stats.get(item, {}) for item in item_list} - else: - attrs = {item: {} for item in item_list} + attrs = {item: {} for item in item_list} + if seen_stats is not None: + for item, stats_dct in seen_stats.items(): + if item in attrs: + attrs[item].update(stats_dct) + if len(item_list) > 0: unhandled_stats = self._get_group_snuba_stats(item_list, seen_stats) @@ -308,8 +390,8 @@ def get_attrs( ) for item in item_list: if filtered_stats: - attrs[item].update({"filtered_stats": filtered_stats[item.id]}) - attrs[item].update({"stats": stats[item.id]}) + attrs[item]["filtered_stats"] = filtered_stats[item.id] + attrs[item]["stats"] = stats[item.id] if self._expand("sessions"): uniq_project_ids = list({item.project_id for item in item_list}) @@ -323,11 +405,7 @@ def get_attrs( missed_items.append(item) else: found = "hit" - attrs[item].update( - { - "sessionCount": num_sessions, - } - ) + attrs[item]["sessionCount"] = num_sessions metrics.incr(f"group.get_session_counts.{found}") if missed_items: @@ -347,33 +425,29 @@ def get_attrs( for item in missed_items: if item.project_id in results.keys(): - attrs[item].update( - { - "sessionCount": results[item.project_id], - } - ) + attrs[item]["sessionCount"] = results[item.project_id] else: - attrs[item].update({"sessionCount": None}) + attrs[item]["sessionCount"] = None if self._expand("inbox"): inbox_stats = get_inbox_details(item_list) for item in item_list: - attrs[item].update({"inbox": inbox_stats.get(item.id)}) + attrs[item]["inbox"] = inbox_stats.get(item.id) if self._expand("owners"): - owner_details = get_owner_details(item_list, user) + owner_details = get_owner_details(item_list) for item in item_list: - attrs[item].update({"owners": owner_details.get(item.id)}) + attrs[item]["owners"] = owner_details.get(item.id) if self._expand("pluginActions"): for item in item_list: action_list = get_actions(item) - attrs[item].update({"pluginActions": action_list}) + attrs[item]["pluginActions"] = action_list if self._expand("pluginIssues"): for item in item_list: plugin_issue_list = get_available_issue_plugins(item) - attrs[item].update({"pluginIssues": plugin_issue_list}) + attrs[item]["pluginIssues"] = plugin_issue_list if self._expand("integrationIssues"): for item in item_list: @@ -385,43 +459,41 @@ def get_attrs( integration_issues = serialize( list(external_issues), serializer=ExternalIssueSerializer() ) - attrs[item].update({"integrationIssues": integration_issues}) + attrs[item]["integrationIssues"] = integration_issues if self._expand("sentryAppIssues"): for item in item_list: - external_issues = PlatformExternalIssue.objects.filter(group_id=item.id) + platform_external_issues = PlatformExternalIssue.objects.filter(group_id=item.id) sentry_app_issues = serialize( - list(external_issues), serializer=PlatformExternalIssueSerializer() + list(platform_external_issues), serializer=PlatformExternalIssueSerializer() ) - attrs[item].update({"sentryAppIssues": sentry_app_issues}) - - if self._expand("latestEventHasAttachments"): - if not features.has( - "organizations:event-attachments", - item.project.organization, - ): - return self.respond(status=404) + attrs[item]["sentryAppIssues"] = sentry_app_issues + if self._expand("latestEventHasAttachments") and features.has( + "organizations:event-attachments", item.project.organization + ): for item in item_list: latest_event = item.get_latest_event() if latest_event is not None: num_attachments = EventAttachment.objects.filter( project_id=latest_event.project_id, event_id=latest_event.event_id ).count() - attrs[item].update({"latestEventHasAttachments": num_attachments > 0}) + attrs[item]["latestEventHasAttachments"] = num_attachments > 0 return attrs - def serialize( - self, obj: Group, attrs: MutableMapping[str, Any], user: Any, **kwargs: Any - ) -> BaseGroupSerializerResponse: + def serialize( # type: ignore[override] # intentionally different shape + self, + obj: Group, + attrs: Mapping[str, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> StreamGroupSerializerSnubaResponse: if not self._collapse("base"): - result = super().serialize(obj, attrs, user) + result: StreamGroupSerializerSnubaResponse = {**super().serialize(obj, attrs, user)} else: - result = { - "id": str(obj.id), - } - if "times_seen" in attrs: + result = {"id": str(obj.id)} + if is_seen_stats(attrs): result.update(self._convert_seen_stats(attrs)) if "is_unhandled" in attrs: result["isUnhandled"] = attrs["is_unhandled"] @@ -433,17 +505,15 @@ def serialize( if not self._collapse("lifetime"): result["lifetime"] = self._convert_seen_stats(attrs["lifetime"]) if self.stats_period: - result["lifetime"].update( - {"stats": None} - ) # Not needed in current implementation + # Not needed in current implementation + result["lifetime"]["stats"] = None if not self._collapse("filtered"): if self.conditions: - result["filtered"] = self._convert_seen_stats(attrs["filtered"]) + filtered = self._convert_seen_stats(attrs["filtered"]) if self.stats_period: - result["filtered"].update( - {"stats": {self.stats_period: attrs["filtered_stats"]}} - ) + filtered["stats"] = {self.stats_period: attrs["filtered_stats"]} + result["filtered"] = filtered else: result["filtered"] = None @@ -589,7 +659,13 @@ def _build_session_cache_key(self, project_id): if self.end: end_key_dt = self.end.replace(second=0, microsecond=0, tzinfo=None) - if end_key_dt and start_key_dt and self.end - self.start >= timedelta(minutes=60): + if ( + self.end + and self.start + and end_key_dt + and start_key_dt + and self.end - self.start >= timedelta(minutes=60) + ): # Cache to the hour for longer time range queries, and to the minute if the query if for a time period under 1 hour end_key_dt = end_key_dt.replace(minute=0) start_key_dt = start_key_dt.replace(minute=0) diff --git a/src/sentry/api/serializers/models/project.py b/src/sentry/api/serializers/models/project.py index f68f85f2e3025e..29f396410215bf 100644 --- a/src/sentry/api/serializers/models/project.py +++ b/src/sentry/api/serializers/models/project.py @@ -80,7 +80,6 @@ PROJECT_FEATURES_NOT_USED_ON_FRONTEND = { "profiling-ingest-unsampled-profiles", "discard-transaction", - "race-free-group-creation", "first-event-severity-calculation", "alert-filters", "servicehooks", diff --git a/src/sentry/api/urls.py b/src/sentry/api/urls.py index 42d63c675b48cc..5bbe099f1047f0 100644 --- a/src/sentry/api/urls.py +++ b/src/sentry/api/urls.py @@ -596,7 +596,6 @@ ProjectPerformanceGeneralSettingsEndpoint, ) from .endpoints.project_performance_issue_settings import ProjectPerformanceIssueSettingsEndpoint -from .endpoints.project_platforms import ProjectPlatformsEndpoint from .endpoints.project_plugin_details import ProjectPluginDetailsEndpoint from .endpoints.project_plugins import ProjectPluginsEndpoint from .endpoints.project_profiling_profile import ( @@ -2225,11 +2224,6 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]: ProjectEnvironmentDetailsEndpoint.as_view(), name="sentry-api-0-project-environment-details", ), - re_path( - r"^(?P[^\/]+)/(?P[^\/]+)/platforms/$", - ProjectPlatformsEndpoint.as_view(), - name="sentry-api-0-project-platform-details", - ), re_path( r"^(?P[^\/]+)/(?P[^\/]+)/events/$", ProjectEventsEndpoint.as_view(), diff --git a/src/sentry/apidocs/examples/project_examples.py b/src/sentry/apidocs/examples/project_examples.py index 6ce49a628eb7bb..e5a19edae513e5 100644 --- a/src/sentry/apidocs/examples/project_examples.py +++ b/src/sentry/apidocs/examples/project_examples.py @@ -54,7 +54,6 @@ "data-forwarding", "discard-groups", "minidump", - "race-free-group-creation", "rate-limits", "servicehooks", "similarity-indexing", diff --git a/src/sentry/apidocs/examples/team_examples.py b/src/sentry/apidocs/examples/team_examples.py index 6b3cf663d978a7..a3fe546318839f 100644 --- a/src/sentry/apidocs/examples/team_examples.py +++ b/src/sentry/apidocs/examples/team_examples.py @@ -209,7 +209,6 @@ class TeamExamples: "data-forwarding", "discard-groups", "minidump", - "race-free-group-creation", "rate-limits", "servicehooks", "similarity-indexing", @@ -269,7 +268,6 @@ class TeamExamples: "data-forwarding", "discard-groups", "minidump", - "race-free-group-creation", "rate-limits", "servicehooks", "similarity-indexing", @@ -377,7 +375,6 @@ class TeamExamples: "data-forwarding", "discard-groups", "minidump", - "race-free-group-creation", "rate-limits", "servicehooks", "similarity-indexing", @@ -449,7 +446,6 @@ class TeamExamples: "data-forwarding", "discard-groups", "minidump", - "race-free-group-creation", "rate-limits", "servicehooks", "similarity-indexing", diff --git a/src/sentry/autofix/utils.py b/src/sentry/autofix/utils.py index b8a016b7f1ae0e..8b81d30433089e 100644 --- a/src/sentry/autofix/utils.py +++ b/src/sentry/autofix/utils.py @@ -10,7 +10,7 @@ from sentry.issues.auto_source_code_config.code_mapping import get_sorted_code_mapping_configs from sentry.models.project import Project from sentry.models.repository import Repository -from sentry.seer.signed_seer_api import get_seer_salted_url, sign_with_seer_secret +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.utils import json @@ -81,16 +81,12 @@ def get_autofix_state( } ) - url, salt = get_seer_salted_url(f"{settings.SEER_AUTOFIX_URL}{path}") response = requests.post( - url, + f"{settings.SEER_AUTOFIX_URL}{path}", data=body, headers={ "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret( - salt, - body=body, - ), + **sign_with_seer_secret(body), }, ) @@ -119,16 +115,12 @@ def get_autofix_state_from_pr_id(provider: str, pr_id: int) -> AutofixState | No } ).encode("utf-8") - url, salt = get_seer_salted_url(f"{settings.SEER_AUTOFIX_URL}{path}") response = requests.post( - url, + f"{settings.SEER_AUTOFIX_URL}{path}", data=body, headers={ "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret( - salt=salt, - body=body, - ), + **sign_with_seer_secret(body), }, ) diff --git a/src/sentry/conf/api_pagination_allowlist_do_not_modify.py b/src/sentry/conf/api_pagination_allowlist_do_not_modify.py index 3b5da2052e9289..ac1be955eabaef 100644 --- a/src/sentry/conf/api_pagination_allowlist_do_not_modify.py +++ b/src/sentry/conf/api_pagination_allowlist_do_not_modify.py @@ -79,7 +79,6 @@ "ProjectIssuesResolvedInReleaseEndpoint", "ProjectMemberIndexEndpoint", "ProjectMonitorStatsEndpoint", - "ProjectPlatformsEndpoint", "ProjectPluginsEndpoint", "ProjectReleaseSetupCompletionEndpoint", "ProjectRuleStatsIndexEndpoint", diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 02c148c38352cf..cc34e79dc742f1 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -982,8 +982,6 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: Queue("replays.delete_replay", routing_key="replays.delete_replay"), Queue("counters-0", routing_key="counters-0"), Queue("triggers-0", routing_key="triggers-0"), - # XXX: Temporarilty keep in place until we have migrated to the new queue - Queue("derive_code_mappings", routing_key="derive_code_mappings"), Queue("auto_source_code_config", routing_key="auto_source_code_config"), Queue("transactions.name_clusterer", routing_key="transactions.name_clusterer"), Queue("auto_enable_codecov", routing_key="auto_enable_codecov"), diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index fecbdb0cedfc23..3242deeac9fdd8 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -561,8 +561,6 @@ def register_temporary_features(manager: FeatureManager): manager.add("projects:first-event-severity-calculation", ProjectFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enable escalation detection for new issues manager.add("projects:first-event-severity-new-escalation", ProjectFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=False) - # Enable alternative version of group creation that is supposed to be less racy. - manager.add("projects:race-free-group-creation", ProjectFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=False) # Enable similarity embeddings API call # This feature is only available on the frontend using project details since the handler gets # project options and this is slow in the project index endpoint feature flag serialization diff --git a/src/sentry/feedback/usecases/create_feedback.py b/src/sentry/feedback/usecases/create_feedback.py index 80beca80ea18b6..12846995995d00 100644 --- a/src/sentry/feedback/usecases/create_feedback.py +++ b/src/sentry/feedback/usecases/create_feedback.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import random from datetime import UTC, datetime from enum import Enum from typing import Any, TypedDict @@ -201,6 +202,25 @@ def should_filter_feedback(event, project_id, source: FeedbackCreationSource): "referrer": source.value, }, ) + # Temporary log for debugging. + if random.random() < 0.1: + project = Project.objects.get_from_cache(id=project_id) + contexts = event.get("contexts", {}) + feedback = contexts.get("feedback", {}) + feedback_msg = feedback.get("message") + logger.info( + "Filtered missing context or message.", + extra={ + "project_id": project_id, + "organization_id": project.organization_id, + "has_contexts": contexts != {}, + "has_feedback": feedback != {}, + "event_type": event.get("type"), + "feedback_message": feedback_msg, + "platform": project.platform, + "referrer": source.value, + }, + ) return True if event["contexts"]["feedback"]["message"] == UNREAL_FEEDBACK_UNATTENDED_MESSAGE: @@ -221,6 +241,17 @@ def should_filter_feedback(event, project_id, source: FeedbackCreationSource): "referrer": source.value, }, ) + # Temporary log for debugging. + project = Project.objects.get_from_cache(id=project_id) + logger.info( + "Filtered empty feedback message.", + extra={ + "project_id": project_id, + "organization_id": project.organization_id, + "platform": project.platform, + "referrer": source.value, + }, + ) return True return False diff --git a/src/sentry/incidents/serializers/alert_rule.py b/src/sentry/incidents/serializers/alert_rule.py index 2631025afb628d..e751c09e83cae3 100644 --- a/src/sentry/incidents/serializers/alert_rule.py +++ b/src/sentry/incidents/serializers/alert_rule.py @@ -47,7 +47,10 @@ get_entity_subscription, ) from sentry.snuba.models import QuerySubscription, SnubaQuery, SnubaQueryEventType -from sentry.workflow_engine.migration_helpers.alert_rule import migrate_alert_rule +from sentry.workflow_engine.migration_helpers.alert_rule import ( + migrate_alert_rule, + migrate_resolve_threshold_data_conditions, +) from ...snuba.metrics.naming_layer.mri import is_mri from . import ( @@ -511,13 +514,15 @@ def create(self, validated_data): extra={"details": str(e)}, ) raise BadRequest - self._handle_triggers(alert_rule, triggers) if features.has( "organizations:workflow-engine-metric-alert-processing", alert_rule.organization ): migrate_alert_rule(alert_rule, user) + if alert_rule.resolve_threshold: + migrate_resolve_threshold_data_conditions(alert_rule) + self._handle_triggers(alert_rule, triggers) return alert_rule def update(self, instance, validated_data): diff --git a/src/sentry/incidents/serializers/alert_rule_trigger.py b/src/sentry/incidents/serializers/alert_rule_trigger.py index 9b229f5c247608..0ca119447c2b68 100644 --- a/src/sentry/incidents/serializers/alert_rule_trigger.py +++ b/src/sentry/incidents/serializers/alert_rule_trigger.py @@ -1,6 +1,7 @@ from django import forms from rest_framework import serializers +from sentry import features from sentry.api.serializers.rest_framework.base import CamelSnakeModelSerializer from sentry.api.serializers.rest_framework.project import ProjectField from sentry.incidents.logic import ( @@ -12,6 +13,7 @@ update_alert_rule_trigger, ) from sentry.incidents.models.alert_rule import AlertRuleTrigger, AlertRuleTriggerAction +from sentry.workflow_engine.migration_helpers.alert_rule import migrate_metric_data_conditions from .alert_rule_trigger_action import AlertRuleTriggerActionSerializer @@ -45,13 +47,19 @@ def create(self, validated_data): ) self._handle_actions(alert_rule_trigger, actions) - return alert_rule_trigger except forms.ValidationError as e: # if we fail in create_alert_rule_trigger, then only one message is ever returned raise serializers.ValidationError(e.error_list[0].message) except AlertRuleTriggerLabelAlreadyUsedError: raise serializers.ValidationError("This label is already in use for this alert rule") + if features.has( + "organizations:workflow-engine-metric-alert-processing", + alert_rule_trigger.alert_rule.organization, + ): + migrate_metric_data_conditions(alert_rule_trigger) + return alert_rule_trigger + def update(self, instance, validated_data): actions = validated_data.pop("actions") if "id" in validated_data: diff --git a/src/sentry/integrations/aws_lambda/utils.py b/src/sentry/integrations/aws_lambda/utils.py index c65ae1c756c259..17a125487bc445 100644 --- a/src/sentry/integrations/aws_lambda/utils.py +++ b/src/sentry/integrations/aws_lambda/utils.py @@ -204,6 +204,35 @@ def get_dsn_for_project(organization_id, project_id): return enabled_dsn.dsn_public +def get_node_options_for_layer(layer_name: str, layer_version: int | None) -> str: + """ + Depending on the SDK major our Lambda Layer represents, a different SDK + package has to be used when setting `NODE_OPTIONS`. + This helper generates the correct options for all the layers we support. + """ + # Lambda layers for v7 of our AWS SDK use the older `@sentry/serverless` SDK + # + # These are specifically + # - `SentryNodeServerlessSDKv7` + # - `SentryNodeServerlessSDK:235` and lower + if layer_name == "SentryNodeServerlessSDKv7" or ( + layer_name == "SentryNodeServerlessSDK" + and layer_version is not None + and layer_version <= 235 + ): + return "-r @sentry/serverless/dist/awslambda-auto" + + # Lambda layers for v8 and above of our AWS SDK use + # the newer `@sentry/aws-serverless` SDK + # + # These are specifically + # - `SentryNodeServerlessSDK:236` and above + # - `SentryNodeServerlessSDKv8` + # - and any other layer with a version suffix above, e.g. + # `SentryNodeServerlessSDKv9` + return "-r @sentry/aws-serverless/awslambda-auto" + + def enable_single_lambda(lambda_client, function, sentry_project_dsn, retries_left=3): # find the latest layer for this function layer_arn = get_latest_layer_for_function(function) @@ -226,6 +255,7 @@ def enable_single_lambda(lambda_client, function, sentry_project_dsn, retries_le if runtime.startswith("nodejs"): # note the env variables would be different for non-Node runtimes + layer_name = get_option_value(function, OPTION_LAYER_NAME) version = get_option_value(function, OPTION_VERSION) try: parsed_version = int(version) @@ -233,24 +263,12 @@ def enable_single_lambda(lambda_client, function, sentry_project_dsn, retries_le sentry_sdk.capture_message("Invariant: Unable to parse AWS lambda version") parsed_version = None - if ( - # Lambda layer version 235 was the latest version using `@sentry/serverless` before we switched to `@sentry/aws-serverless` - parsed_version is not None - and parsed_version <= 235 - ): - env_variables.update( - { - "NODE_OPTIONS": "-r @sentry/serverless/dist/awslambda-auto", - **sentry_env_variables, - } - ) - else: - env_variables.update( - { - "NODE_OPTIONS": "-r @sentry/aws-serverless/awslambda-auto", - **sentry_env_variables, - } - ) + env_variables.update( + { + "NODE_OPTIONS": get_node_options_for_layer(layer_name, parsed_version), + **sentry_env_variables, + } + ) elif runtime.startswith("python"): # Check if we are trying to re-enable an already enabled python, and if diff --git a/src/sentry/integrations/pagerduty/utils.py b/src/sentry/integrations/pagerduty/utils.py index c2b6624d986034..779343dee05451 100644 --- a/src/sentry/integrations/pagerduty/utils.py +++ b/src/sentry/integrations/pagerduty/utils.py @@ -10,6 +10,7 @@ from sentry.incidents.models.incident import Incident, IncidentStatus from sentry.integrations.metric_alerts import incident_attachment_info from sentry.integrations.models.organization_integration import OrganizationIntegration +from sentry.integrations.pagerduty.client import PAGERDUTY_DEFAULT_SEVERITY from sentry.integrations.services.integration import integration_service from sentry.integrations.services.integration.model import RpcOrganizationIntegration from sentry.shared_integrations.client.proxy import infer_org_integration @@ -131,7 +132,7 @@ def attach_custom_severity( return data severity = app_config.get("priority", None) - if severity is not None: + if severity is not None and severity != PAGERDUTY_DEFAULT_SEVERITY: data["payload"]["severity"] = severity return data diff --git a/src/sentry/integrations/repository/issue_alert.py b/src/sentry/integrations/repository/issue_alert.py index bc0d00b212b4f1..2632e6bcd4dca7 100644 --- a/src/sentry/integrations/repository/issue_alert.py +++ b/src/sentry/integrations/repository/issue_alert.py @@ -2,6 +2,7 @@ from collections.abc import Generator from dataclasses import dataclass +from datetime import datetime from logging import Logger, getLogger from django.db.models import Q @@ -22,6 +23,7 @@ class IssueAlertNotificationMessage(BaseNotificationMessage): # TODO: https://github.com/getsentry/sentry/issues/66751 rule_fire_history: RuleFireHistory | None = None rule_action_uuid: str | None = None + open_period_start: datetime | None = None @classmethod def from_model(cls, instance: NotificationMessage) -> IssueAlertNotificationMessage: @@ -37,6 +39,7 @@ def from_model(cls, instance: NotificationMessage) -> IssueAlertNotificationMess ), rule_fire_history=instance.rule_fire_history, rule_action_uuid=instance.rule_action_uuid, + open_period_start=instance.open_period_start, date_added=instance.date_added, ) @@ -55,6 +58,7 @@ class RuleFireHistoryAndRuleActionUuidActionValidationError( class NewIssueAlertNotificationMessage(BaseNewNotificationMessage): rule_fire_history_id: int | None = None rule_action_uuid: str | None = None + open_period_start: datetime | None = None def get_validation_error(self) -> Exception | None: error = super().get_validation_error() @@ -100,7 +104,11 @@ def _parent_notification_message_base_filter(cls) -> Q: return Q(parent_notification_message__isnull=True, error_code__isnull=True) def get_parent_notification_message( - self, rule_id: int, group_id: int, rule_action_uuid: str + self, + rule_id: int, + group_id: int, + rule_action_uuid: str, + open_period_start: datetime | None = None, ) -> IssueAlertNotificationMessage | None: """ Returns the parent notification message for a metric rule if it exists, otherwise returns None. @@ -114,6 +122,7 @@ def get_parent_notification_message( rule_fire_history__rule__id=rule_id, rule_fire_history__group__id=group_id, rule_action_uuid=rule_action_uuid, + open_period_start=open_period_start, ) .latest("date_added") ) @@ -146,6 +155,7 @@ def create_notification_message( parent_notification_message_id=data.parent_notification_message_id, rule_fire_history_id=data.rule_fire_history_id, rule_action_uuid=data.rule_action_uuid, + open_period_start=data.open_period_start, ) return IssueAlertNotificationMessage.from_model(instance=new_instance) except Exception as e: @@ -157,7 +167,10 @@ def create_notification_message( raise def get_all_parent_notification_messages_by_filters( - self, group_ids: list[int] | None = None, project_ids: list[int] | None = None + self, + group_ids: list[int] | None = None, + project_ids: list[int] | None = None, + open_period_start: datetime | None = None, ) -> Generator[IssueAlertNotificationMessage]: """ If no filters are passed, then all parent notification objects are returned. @@ -168,11 +181,14 @@ def get_all_parent_notification_messages_by_filters( """ group_id_filter = Q(rule_fire_history__group__id__in=group_ids) if group_ids else Q() project_id_filter = Q(rule_fire_history__project_id__in=project_ids) if project_ids else Q() - - query = self._model.objects.filter(group_id_filter & project_id_filter).filter( - self._parent_notification_message_base_filter() + open_period_start_filter = ( + Q(open_period_start=open_period_start) if open_period_start else Q() ) + query = self._model.objects.filter( + group_id_filter & project_id_filter & open_period_start_filter + ).filter(self._parent_notification_message_base_filter()) + try: for instance in query: yield IssueAlertNotificationMessage.from_model(instance=instance) diff --git a/src/sentry/integrations/slack/actions/notification.py b/src/sentry/integrations/slack/actions/notification.py index d6e633b24e66ed..e64a7c6f967dce 100644 --- a/src/sentry/integrations/slack/actions/notification.py +++ b/src/sentry/integrations/slack/actions/notification.py @@ -1,12 +1,14 @@ from __future__ import annotations from collections.abc import Generator, Sequence +from datetime import datetime from logging import Logger, getLogger from typing import Any import orjson from slack_sdk.errors import SlackApiError +from sentry import features from sentry.api.serializers.rest_framework.rule import ACTION_UUID_KEY from sentry.constants import ISSUE_ALERTS_THREAD_DEFAULT from sentry.eventstore.models import GroupEvent @@ -39,9 +41,11 @@ unpack_slack_api_error, ) from sentry.integrations.utils.metrics import EventLifecycle +from sentry.issues.grouptype import GroupCategory from sentry.models.options.organization_option import OrganizationOption from sentry.models.rule import Rule from sentry.notifications.additional_attachment_manager import get_additional_attachment +from sentry.notifications.utils.open_period import open_period_start_for_group from sentry.rules.actions import IntegrationEventAction from sentry.rules.base import CallbackFuture from sentry.types.rules import RuleFuture @@ -129,6 +133,17 @@ def send_notification(event: GroupEvent, futures: Sequence[RuleFuture]) -> None: rule_action_uuid=rule_action_uuid, ) + open_period_start: datetime | None = None + if ( + features.has( + "organizations:slack-threads-refactor-uptime", self.project.organization + ) + and event.group.issue_category == GroupCategory.UPTIME + ): + open_period_start = open_period_start_for_group(event.group) + # Save in the notification message object so it can be used in the repository + new_notification_message_object.open_period_start = open_period_start + def get_thread_ts(lifecycle: EventLifecycle) -> str | None: """Find the thread in which to post this notification as a reply. @@ -152,6 +167,7 @@ def get_thread_ts(lifecycle: EventLifecycle) -> str | None: rule_id=rule_id, group_id=event.group.id, rule_action_uuid=rule_action_uuid, + open_period_start=open_period_start, ) except Exception as e: lifecycle.record_halt(e) diff --git a/src/sentry/integrations/slack/unfurl/discover.py b/src/sentry/integrations/slack/unfurl/discover.py index eccb01927e41ae..8a84f674f9acea 100644 --- a/src/sentry/integrations/slack/unfurl/discover.py +++ b/src/sentry/integrations/slack/unfurl/discover.py @@ -176,7 +176,11 @@ def _unfurl_discover( ) params.setlist("name", params.getlist("name") or to_list(saved_query.get("name"))) - saved_query_dataset = dataset_map.get(saved_query.get("queryDataset")) + query_dataset = saved_query.get("queryDataset") + if query_dataset is not None: + saved_query_dataset = dataset_map.get(query_dataset) + else: + saved_query_dataset = None params.setlist( "dataset", params.getlist("dataset") diff --git a/src/sentry/integrations/slack/utils/channel.py b/src/sentry/integrations/slack/utils/channel.py index 19f5579673c6b9..b739ab49a56cca 100644 --- a/src/sentry/integrations/slack/utils/channel.py +++ b/src/sentry/integrations/slack/utils/channel.py @@ -90,7 +90,7 @@ def get_channel_id( return get_channel_id_with_timeout(integration, channel_name, timeout) -def validate_channel_id(name: str, integration_id: int | None, input_channel_id: str) -> None: +def validate_channel_id(name: str, integration_id: int, input_channel_id: str) -> None: """ In the case that the user is creating an alert via the API and providing the channel ID and name themselves, we want to make sure both values are correct. @@ -180,7 +180,7 @@ def get_channel_id_with_timeout( def check_user_with_timeout( - integration: Integration, name: str, time_to_quit: int + integration: Integration | RpcIntegration, name: str, time_to_quit: float ) -> SlackChannelIdData: """ If the channel is not found, we check if the name is a user. @@ -297,15 +297,15 @@ def check_for_channel( try: client.chat_deleteScheduledMessage( - channel=msg_response.get("channel"), - scheduled_message_id=msg_response.get("scheduled_message_id"), + channel=msg_response["channel"], + scheduled_message_id=msg_response["scheduled_message_id"], ) metrics.incr( SLACK_UTILS_CHANNEL_SUCCESS_DATADOG_METRIC, sample_rate=1.0, tags={"type": "chat_deleteScheduledMessage"}, ) - return msg_response.get("channel") + return msg_response["channel"] except SlackApiError as e: metrics.incr( SLACK_UTILS_CHANNEL_FAILURE_DATADOG_METRIC, diff --git a/src/sentry/integrations/slack/utils/users.py b/src/sentry/integrations/slack/utils/users.py index 9b3c57c5e2fbd6..bf11ce90ead311 100644 --- a/src/sentry/integrations/slack/utils/users.py +++ b/src/sentry/integrations/slack/utils/users.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from collections.abc import Iterable, Mapping, MutableMapping +from collections.abc import Generator, Iterable, Mapping, MutableMapping from dataclasses import dataclass from typing import Any @@ -32,7 +32,7 @@ class SlackUserData: slack_id: str -def format_slack_info_by_email(users: dict[str, Any]) -> dict[str, SlackUserData]: +def format_slack_info_by_email(users: list[dict[str, Any]]) -> dict[str, SlackUserData]: return { member["profile"]["email"]: SlackUserData( email=member["profile"]["email"], team_id=member["team_id"], slack_id=member["id"] @@ -43,7 +43,7 @@ def format_slack_info_by_email(users: dict[str, Any]) -> dict[str, SlackUserData def format_slack_data_by_user( - emails_by_user: Mapping[User, Iterable[str]], users: dict[str, Any] + emails_by_user: Mapping[User, Iterable[str]], users: list[dict[str, Any]] ) -> Mapping[User, SlackUserData]: slack_info_by_email = format_slack_info_by_email(users) @@ -60,7 +60,7 @@ def get_slack_user_list( integration: Integration | RpcIntegration, organization: Organization | RpcOrganization | None = None, kwargs: dict[str, Any] | None = None, -) -> Iterable[dict[str, Any]]: +) -> Generator[list[dict[str, Any]]]: sdk_client = SlackSdkClient(integration_id=integration.id) try: users_list = ( @@ -71,9 +71,7 @@ def get_slack_user_list( metrics.incr(SLACK_UTILS_GET_USER_LIST_SUCCESS_DATADOG_METRIC, sample_rate=1.0) for page in users_list: - users: dict[str, Any] = page.get("members") - - yield users + yield page["members"] except SlackApiError as e: metrics.incr(SLACK_UTILS_GET_USER_LIST_FAILURE_DATADOG_METRIC, sample_rate=1.0) _logger.info( diff --git a/src/sentry/integrations/source_code_management/repository.py b/src/sentry/integrations/source_code_management/repository.py index 3fbb2f746342d9..925032e7a39315 100644 --- a/src/sentry/integrations/source_code_management/repository.py +++ b/src/sentry/integrations/source_code_management/repository.py @@ -118,7 +118,7 @@ def check_file(self, repo: Repository, filepath: str, branch: str | None = None) filepath: file from the stacktrace (string) branch: commitsha or default_branch (string) """ - with self.record_event(SCMIntegrationInteractionType.CHECK_FILE).capture(): + with self.record_event(SCMIntegrationInteractionType.CHECK_FILE).capture() as lifecycle: filepath = filepath.lstrip("/") try: client = self.get_client() @@ -132,12 +132,13 @@ def check_file(self, repo: Repository, filepath: str, branch: str | None = None) except IdentityNotValid: return None except ApiError as e: - if e.code != 404: + if e.code in (404, 400): + lifecycle.record_halt(e) + return None + else: sentry_sdk.capture_exception() raise - return None - return self.format_source_url(repo, filepath, branch) def get_stacktrace_link( diff --git a/src/sentry/issues/endpoints/group_details.py b/src/sentry/issues/endpoints/group_details.py index 702f06f1654826..d52046dd7cf518 100644 --- a/src/sentry/issues/endpoints/group_details.py +++ b/src/sentry/issues/endpoints/group_details.py @@ -246,7 +246,7 @@ def get(self, request: Request, group: Group) -> Response: data.update({"inbox": inbox_reason}) if "owners" in expand: - owner_details = get_owner_details([group], request.user) + owner_details = get_owner_details([group]) owners = owner_details.get(group.id) data.update({"owners": owners}) diff --git a/src/sentry/issues/endpoints/project_group_index.py b/src/sentry/issues/endpoints/project_group_index.py index 8f5c9d956da839..aa699770426c89 100644 --- a/src/sentry/issues/endpoints/project_group_index.py +++ b/src/sentry/issues/endpoints/project_group_index.py @@ -97,8 +97,7 @@ def get(self, request: Request, project: Project) -> Response: # disable stats stats_period = None - serializer = functools.partial( - StreamGroupSerializer, + serializer = StreamGroupSerializer( environment_func=self._get_environment_func(request, project.organization_id), stats_period=stats_period, ) @@ -117,11 +116,7 @@ def get(self, request: Request, project: Project) -> Response: ).values_list("group_id", flat=True) groups = list(Group.objects.filter(id__in=groups_from_hashes)) - serialized_groups = serialize( - groups, - request.user, - serializer(), - ) + serialized_groups = serialize(groups, request.user, serializer) return Response(serialized_groups) if query: @@ -153,11 +148,7 @@ def get(self, request: Request, project: Project) -> Response: except Environment.DoesNotExist: pass - serialized_groups = serialize( - [matching_group], - request.user, - serializer(), - ) + serialized_groups = serialize([matching_group], request.user, serializer) matching_event_id = getattr(matching_event, "event_id", None) if matching_event_id: serialized_groups[0]["matchingEventId"] = getattr( @@ -178,7 +169,7 @@ def get(self, request: Request, project: Project) -> Response: results = list(cursor_result) - context = serialize(results, request.user, serializer()) + context = serialize(results, request.user, serializer) # HACK: remove auto resolved entries # TODO: We should try to integrate this into the search backend, since diff --git a/src/sentry/lang/javascript/processing.py b/src/sentry/lang/javascript/processing.py index 3c815e6774f5d2..b3cea5f30b759c 100644 --- a/src/sentry/lang/javascript/processing.py +++ b/src/sentry/lang/javascript/processing.py @@ -14,8 +14,9 @@ logger = logging.getLogger(__name__) # Matches "app:", "webpack:", +# "http:", "https:", # "x:" where x is a single ASCII letter, or "/". -NON_BUILTIN_PATH_REGEX = re.compile(r"^((app|webpack|[a-zA-Z]):|/)") +NON_BUILTIN_PATH_REGEX = re.compile(r"^((app|webpack|[a-zA-Z]|https?):|/)") def _merge_frame_context(new_frame, symbolicated): diff --git a/src/sentry/models/groupowner.py b/src/sentry/models/groupowner.py index 2add113911c024..fac9bc84264826 100644 --- a/src/sentry/models/groupowner.py +++ b/src/sentry/models/groupowner.py @@ -2,6 +2,7 @@ import itertools from collections import defaultdict +from collections.abc import Sequence from datetime import datetime, timedelta from enum import Enum from typing import Any, TypedDict @@ -177,7 +178,7 @@ def invalidate_assignee_exists_cache(cls, project_id, group_id=None): cache.delete_many(cache_keys) -def get_owner_details(group_list: list[Group], user: Any) -> dict[int, list[OwnersSerialized]]: +def get_owner_details(group_list: Sequence[Group]) -> dict[int, list[OwnersSerialized]]: group_ids = [g.id for g in group_list] group_owners = GroupOwner.objects.filter(group__in=group_ids).exclude( user_id__isnull=True, team_id__isnull=True diff --git a/src/sentry/models/organizationonboardingtask.py b/src/sentry/models/organizationonboardingtask.py index 917133dee918ed..67829b8dde11f1 100644 --- a/src/sentry/models/organizationonboardingtask.py +++ b/src/sentry/models/organizationonboardingtask.py @@ -29,7 +29,6 @@ class OnboardingTask: SECOND_PLATFORM = 4 RELEASE_TRACKING = 6 SOURCEMAPS = 7 - ISSUE_TRACKER = 9 ALERT_RULE = 10 FIRST_TRANSACTION = 11 SESSION_REPLAY = 14 @@ -47,7 +46,6 @@ class OnboardingTaskStatus: # # FIRST_EVENT: { 'platform': 'flask', } # INVITE_MEMBER: { 'invited_member': user.id, 'teams': [team.id] } -# ISSUE_TRACKER: { 'plugin': 'plugin_name' } # SECOND_PLATFORM: { 'platform': 'javascript' } # # NOTE: Currently the `PENDING` status is applicable for the following @@ -55,7 +53,6 @@ class OnboardingTaskStatus: # # FIRST_EVENT: User confirms that sdk has been installed # INVITE_MEMBER: Until the member has successfully joined org -# ISSUE_TRACKER: Tracker added, issue not yet created class OrganizationOnboardingTaskManager(BaseManager["OrganizationOnboardingTask"]): @@ -122,9 +119,6 @@ class OrganizationOnboardingTask(AbstractOnboardingTask): (OnboardingTask.SECOND_PLATFORM, "setup_second_platform"), (OnboardingTask.RELEASE_TRACKING, "setup_release_tracking"), (OnboardingTask.SOURCEMAPS, "setup_sourcemaps"), - # TODO(Telemety Experience): This task is no longer shown - # in the new experience and shall remove it from code - (OnboardingTask.ISSUE_TRACKER, "setup_issue_tracker"), (OnboardingTask.ALERT_RULE, "setup_alert_rules"), (OnboardingTask.FIRST_TRANSACTION, "setup_transactions"), (OnboardingTask.SESSION_REPLAY, "setup_session_replay"), diff --git a/src/sentry/notifications/notifications/registries/__init__.py b/src/sentry/notifications/notifications/registries/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/notifications/notifications/registries/thread_lookup_registry b/src/sentry/notifications/notifications/registries/thread_lookup_registry new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 8143f4054a7c99..133eb8cf02be76 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -57,7 +57,6 @@ default=2**31, flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) -register("system.new-auto-source-code-config-queue", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) # URL configuration # Absolute URL to the sentry root directory. Should not include a trailing slash. @@ -760,10 +759,6 @@ # Killswitch to stop storing any reprocessing payloads. register("store.reprocessing-force-disable", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE) -register( - "store.race-free-group-creation-force-disable", default=False, flags=FLAG_AUTOMATOR_MODIFIABLE -) - # Enable calling the severity modeling API on group creation register( "processing.calculate-severity-on-group-creation", @@ -2824,12 +2819,6 @@ "ecosystem:enable_integration_form_error_raise", default=True, flags=FLAG_AUTOMATOR_MODIFIABLE ) -# Controls the rate of using the sentry api shared secret for communicating to sentry. -register( - "seer.api.use-nonce-signature", - default=0.0, - flags=FLAG_AUTOMATOR_MODIFIABLE, -) # Restrict uptime issue creation for specific host provider identifiers. Items # in this list map to the `host_provider_id` column in the UptimeSubscription diff --git a/src/sentry/organizations/services/organization/model.py b/src/sentry/organizations/services/organization/model.py index 418e714cb84a94..49626e113a4bd3 100644 --- a/src/sentry/organizations/services/organization/model.py +++ b/src/sentry/organizations/services/organization/model.py @@ -142,6 +142,7 @@ class RpcOrganizationMember(RpcOrganizationMemberSummary): token_expired: bool = False legacy_token: str = "" email: str = "" + invitation_link: str | None = None def get_audit_log_metadata(self, user_email: str | None = None) -> Mapping[str, Any]: from sentry.models.organizationmember import invite_status_names diff --git a/src/sentry/organizations/services/organization/serial.py b/src/sentry/organizations/services/organization/serial.py index c94bfbc4e0d132..a223920ea342ca 100644 --- a/src/sentry/organizations/services/organization/serial.py +++ b/src/sentry/organizations/services/organization/serial.py @@ -54,6 +54,7 @@ def serialize_member(member: OrganizationMember) -> RpcOrganizationMember: token_expired=member.token_expired, legacy_token=member.legacy_token, email=member.get_email(), + invitation_link=member.get_invite_link(), ) omts = OrganizationMemberTeam.objects.filter( diff --git a/src/sentry/plugins/bases/issue.py b/src/sentry/plugins/bases/issue.py index 90bd132246072b..a9a42e0ac31dfb 100644 --- a/src/sentry/plugins/bases/issue.py +++ b/src/sentry/plugins/bases/issue.py @@ -4,10 +4,10 @@ from django.conf import settings from rest_framework.request import Request +from sentry import analytics from sentry.models.activity import Activity from sentry.models.groupmeta import GroupMeta from sentry.plugins.base.v1 import Plugin -from sentry.signals import issue_tracker_used from sentry.types.activity import ActivityType from sentry.users.services.usersocialauth.model import RpcUserSocialAuth from sentry.users.services.usersocialauth.service import usersocialauth_service @@ -207,12 +207,15 @@ def view(self, request: Request, group, **kwargs): data=issue_information, ) - issue_tracker_used.send_robust( - plugin=self, - project=group.project, - user=request.user, - sender=IssueTrackingPlugin, + analytics.record( + "issue_tracker.used", + user_id=request.user.id, + default_user_id=project.organization.get_default_owner().id, + organization_id=project.organization_id, + project_id=project.id, + issue_tracker=self.slug, ) + return self.redirect(group.get_absolute_url()) context = { diff --git a/src/sentry/plugins/bases/issue2.py b/src/sentry/plugins/bases/issue2.py index 81bd500dacabf1..fb9a463d3f821e 100644 --- a/src/sentry/plugins/bases/issue2.py +++ b/src/sentry/plugins/bases/issue2.py @@ -9,6 +9,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry import analytics from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases.group import GroupEndpoint @@ -21,7 +22,6 @@ from sentry.models.group import Group from sentry.models.groupmeta import GroupMeta from sentry.plugins.base.v1 import Plugin -from sentry.signals import issue_tracker_used from sentry.types.activity import ActivityType from sentry.users.services.usersocialauth.model import RpcUserSocialAuth from sentry.users.services.usersocialauth.service import usersocialauth_service @@ -313,9 +313,15 @@ def view_create(self, request: Request, group, **kwargs): data=issue_information, ) - issue_tracker_used.send_robust( - plugin=self, project=group.project, user=request.user, sender=type(self) + analytics.record( + "issue_tracker.used", + user_id=request.user.id, + default_user_id=group.project.organization.get_default_owner().id, + organization_id=group.project.organization_id, + project_id=group.project.id, + issue_tracker=self.slug, ) + return Response( { "issue_url": self.get_issue_url(group, issue["id"]), diff --git a/src/sentry/receivers/features.py b/src/sentry/receivers/features.py index 7eb48ad9971717..941ff9eebea2e6 100644 --- a/src/sentry/receivers/features.py +++ b/src/sentry/receivers/features.py @@ -368,6 +368,13 @@ def record_alert_rule_edited( @plugin_enabled.connect(weak=False) def record_plugin_enabled(plugin, project, user, **kwargs): + analytics.record( + "plugin.enabled", + user_id=user.id if user else None, + organization_id=project.organization_id, + project_id=project.id, + plugin=plugin.slug, + ) if isinstance(plugin, (IssueTrackingPlugin, IssueTrackingPlugin2)): FeatureAdoption.objects.record( organization_id=project.organization_id, diff --git a/src/sentry/receivers/onboarding.py b/src/sentry/receivers/onboarding.py index 15bf5b41738862..dbdfba59eab8e5 100644 --- a/src/sentry/receivers/onboarding.py +++ b/src/sentry/receivers/onboarding.py @@ -18,8 +18,6 @@ ) from sentry.models.project import Project from sentry.onboarding_tasks import try_mark_onboarding_complete -from sentry.plugins.bases.issue import IssueTrackingPlugin -from sentry.plugins.bases.issue2 import IssueTrackingPlugin2 from sentry.signals import ( alert_rule_created, cron_monitor_created, @@ -37,10 +35,8 @@ first_replay_received, first_transaction_received, integration_added, - issue_tracker_used, member_invited, member_joined, - plugin_enabled, project_created, transaction_processed, ) @@ -563,34 +559,6 @@ def record_sourcemaps_received_for_project(project, event, **kwargs): ) -@plugin_enabled.connect(weak=False) -def record_plugin_enabled(plugin, project, user, **kwargs): - if isinstance(plugin, IssueTrackingPlugin) or isinstance(plugin, IssueTrackingPlugin2): - task = OnboardingTask.ISSUE_TRACKER - status = OnboardingTaskStatus.PENDING - else: - return - - success = OrganizationOnboardingTask.objects.record( - organization_id=project.organization_id, - task=task, - status=status, - user_id=user.id if user else None, - project_id=project.id, - data={"plugin": plugin.slug}, - ) - if success: - try_mark_onboarding_complete(project.organization_id) - - analytics.record( - "plugin.enabled", - user_id=user.id if user else None, - organization_id=project.organization_id, - project_id=project.id, - plugin=plugin.slug, - ) - - @alert_rule_created.connect(weak=False) def record_alert_rule_created(user, project: Project, rule_type: str, **kwargs): # The quick start now only has a task for issue alert rules. @@ -612,47 +580,6 @@ def record_alert_rule_created(user, project: Project, rule_type: str, **kwargs): try_mark_onboarding_complete(project.organization_id) -@issue_tracker_used.connect(weak=False) -def record_issue_tracker_used(plugin, project, user, **kwargs): - rows_affected, created = OrganizationOnboardingTask.objects.create_or_update( - organization_id=project.organization_id, - task=OnboardingTask.ISSUE_TRACKER, - status=OnboardingTaskStatus.PENDING, - values={ - "status": OnboardingTaskStatus.COMPLETE, - "user_id": user.id, - "project_id": project.id, - "date_completed": django_timezone.now(), - "data": {"plugin": plugin.slug}, - }, - ) - - if rows_affected or created: - try_mark_onboarding_complete(project.organization_id) - - if user and user.is_authenticated: - user_id = default_user_id = user.id - else: - user_id = None - try: - default_user_id = project.organization.get_default_owner().id - except IndexError: - logger.warning( - "Cannot record issue tracker used for organization (%s) due to missing owners", - project.organization_id, - ) - return - - analytics.record( - "issue_tracker.used", - user_id=user_id, - default_user_id=default_user_id, - organization_id=project.organization_id, - project_id=project.id, - issue_tracker=plugin.slug, - ) - - @integration_added.connect(weak=False) def record_integration_added( integration_id: int, organization_id: int, user_id: int | None, **kwargs diff --git a/src/sentry/search/eap/columns.py b/src/sentry/search/eap/columns.py index 0f11824f2b2dd5..626f61ebea830c 100644 --- a/src/sentry/search/eap/columns.py +++ b/src/sentry/search/eap/columns.py @@ -1,9 +1,10 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import Any, Literal +from typing import Any from dateutil.tz import tz +from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ( AttributeAggregation, AttributeKey, @@ -14,10 +15,7 @@ from sentry.exceptions import InvalidSearchQuery from sentry.search.eap import constants -from sentry.search.events.constants import DURATION_UNITS, SIZE_UNITS, SPAN_MODULE_CATEGORY_VALUES from sentry.search.events.types import SnubaParams -from sentry.search.utils import DEVICE_CLASS -from sentry.utils.validators import is_event_id, is_span_id @dataclass(frozen=True, kw_only=True) @@ -74,11 +72,6 @@ def proto_definition(self) -> AttributeKey: ) -SIZE_TYPE: set[constants.SearchType] = set(SIZE_UNITS.keys()) - -DURATION_TYPE: set[constants.SearchType] = set(DURATION_UNITS.keys()) - - @dataclass class ArgumentDefinition: argument_types: set[constants.SearchType] | None = None @@ -164,511 +157,9 @@ def datetime_processor(datetime_string: str) -> str: return datetime.fromisoformat(datetime_string).replace(tzinfo=tz.tzutc()).isoformat() -SPAN_COLUMN_DEFINITIONS = { - column.public_alias: column - for column in [ - ResolvedColumn( - public_alias="id", - internal_name="sentry.span_id", - search_type="string", - validator=is_span_id, - ), - ResolvedColumn( - public_alias="parent_span", - internal_name="sentry.parent_span_id", - search_type="string", - validator=is_span_id, - ), - ResolvedColumn( - public_alias="organization.id", - internal_name="sentry.organization_id", - search_type="string", - ), - ResolvedColumn( - public_alias="project.id", - internal_name="sentry.project_id", - internal_type=constants.INT, - search_type="string", - ), - ResolvedColumn( - public_alias="project_id", - internal_name="sentry.project_id", - internal_type=constants.INT, - search_type="string", - secondary_alias=True, - ), - ResolvedColumn( - public_alias="span.action", - internal_name="sentry.action", - search_type="string", - ), - ResolvedColumn( - public_alias="span.description", - internal_name="sentry.name", - search_type="string", - ), - ResolvedColumn( - public_alias="description", - internal_name="sentry.name", - search_type="string", - secondary_alias=True, - ), - # Message maps to description, this is to allow wildcard searching - ResolvedColumn( - public_alias="message", - internal_name="sentry.name", - search_type="string", - secondary_alias=True, - ), - ResolvedColumn( - public_alias="span.domain", - internal_name="sentry.domain", - search_type="string", - ), - ResolvedColumn( - public_alias="span.group", - internal_name="sentry.group", - search_type="string", - ), - ResolvedColumn( - public_alias="span.op", - internal_name="sentry.op", - search_type="string", - ), - ResolvedColumn( - public_alias="span.category", - internal_name="sentry.category", - search_type="string", - ), - ResolvedColumn( - public_alias="span.self_time", - internal_name="sentry.exclusive_time_ms", - search_type="millisecond", - ), - ResolvedColumn( - public_alias="span.duration", - internal_name="sentry.duration_ms", - search_type="millisecond", - ), - ResolvedColumn( - public_alias="span.status", - internal_name="sentry.status", - search_type="string", - ), - ResolvedColumn( - public_alias="span.status_code", - internal_name="sentry.status_code", - search_type="string", - ), - ResolvedColumn( - public_alias="trace", - internal_name="sentry.trace_id", - search_type="string", - validator=is_event_id, - ), - ResolvedColumn( - public_alias="transaction", - internal_name="sentry.segment_name", - search_type="string", - ), - ResolvedColumn( - public_alias="is_transaction", - internal_name="sentry.is_segment", - search_type="boolean", - ), - ResolvedColumn( - public_alias="transaction.span_id", - internal_name="sentry.segment_id", - search_type="string", - ), - ResolvedColumn( - public_alias="profile.id", - internal_name="sentry.profile_id", - search_type="string", - ), - ResolvedColumn( - public_alias="replay.id", - internal_name="sentry.replay_id", - search_type="string", - ), - ResolvedColumn( - public_alias="span.ai.pipeline.group", - internal_name="sentry.ai_pipeline_group", - search_type="string", - ), - ResolvedColumn( - public_alias="ai.total_tokens.used", - internal_name="ai_total_tokens_used", - search_type="number", - ), - ResolvedColumn( - public_alias="ai.total_cost", - internal_name="ai.total_cost", - search_type="number", - ), - ResolvedColumn( - public_alias="http.decoded_response_content_length", - internal_name="http.decoded_response_content_length", - search_type="byte", - ), - ResolvedColumn( - public_alias="http.response_content_length", - internal_name="http.response_content_length", - search_type="byte", - ), - ResolvedColumn( - public_alias="http.response_transfer_size", - internal_name="http.response_transfer_size", - search_type="byte", - ), - ResolvedColumn( - public_alias="sampling_rate", - internal_name="sentry.sampling_factor", - search_type="percentage", - ), - ResolvedColumn( - public_alias="timestamp", - internal_name="sentry.timestamp", - search_type="string", - processor=datetime_processor, - ), - ResolvedColumn( - public_alias="mobile.frames_delay", - internal_name="frames.delay", - search_type="second", - ), - ResolvedColumn( - public_alias="mobile.frames_slow", - internal_name="frames.slow", - search_type="number", - ), - ResolvedColumn( - public_alias="mobile.frames_frozen", - internal_name="frames.frozen", - search_type="number", - ), - ResolvedColumn( - public_alias="mobile.frames_total", - internal_name="frames.total", - search_type="number", - ), - # These fields are extracted from span measurements but were accessed - # 2 ways, with + without the measurements. prefix. So expose both for compatibility. - simple_measurements_field("cache.item_size", search_type="byte", secondary_alias=True), - ResolvedColumn( - public_alias="cache.item_size", - internal_name="cache.item_size", - search_type="byte", - ), - simple_measurements_field( - "messaging.message.body.size", search_type="byte", secondary_alias=True - ), - ResolvedColumn( - public_alias="messaging.message.body.size", - internal_name="messaging.message.body.size", - search_type="byte", - ), - simple_measurements_field( - "messaging.message.receive.latency", search_type="millisecond", secondary_alias=True - ), - ResolvedColumn( - public_alias="messaging.message.receive.latency", - internal_name="messaging.message.receive.latency", - search_type="millisecond", - ), - simple_measurements_field("messaging.message.retry.count", secondary_alias=True), - ResolvedColumn( - public_alias="messaging.message.retry.count", - internal_name="messaging.message.retry.count", - search_type="number", - ), - simple_sentry_field("browser.name"), - simple_sentry_field("environment"), - simple_sentry_field("messaging.destination.name"), - simple_sentry_field("messaging.message.id"), - simple_sentry_field("platform"), - simple_sentry_field("raw_domain"), - simple_sentry_field("release"), - simple_sentry_field("sdk.name"), - simple_sentry_field("sdk.version"), - simple_sentry_field("span_id"), - simple_sentry_field("trace.status"), - simple_sentry_field("transaction.method"), - simple_sentry_field("transaction.op"), - simple_sentry_field("user"), - simple_sentry_field("user.email"), - simple_sentry_field("user.geo.country_code"), - simple_sentry_field("user.geo.subregion"), - simple_sentry_field("user.id"), - simple_sentry_field("user.ip"), - simple_sentry_field("user.username"), - simple_measurements_field("app_start_cold", "millisecond"), - simple_measurements_field("app_start_warm", "millisecond"), - simple_measurements_field("frames_frozen"), - simple_measurements_field("frames_frozen_rate", "percentage"), - simple_measurements_field("frames_slow"), - simple_measurements_field("frames_slow_rate", "percentage"), - simple_measurements_field("frames_total"), - simple_measurements_field("time_to_initial_display", "millisecond"), - simple_measurements_field("time_to_full_display", "millisecond"), - simple_measurements_field("stall_count"), - simple_measurements_field("stall_percentage", "percentage"), - simple_measurements_field("stall_stall_longest_time"), - simple_measurements_field("stall_stall_total_time"), - simple_measurements_field("cls"), - simple_measurements_field("fcp", "millisecond"), - simple_measurements_field("fid", "millisecond"), - simple_measurements_field("fp", "millisecond"), - simple_measurements_field("inp", "millisecond"), - simple_measurements_field("lcp", "millisecond"), - simple_measurements_field("ttfb", "millisecond"), - simple_measurements_field("ttfb.requesttime", "millisecond"), - simple_measurements_field("score.cls"), - simple_measurements_field("score.fcp"), - simple_measurements_field("score.fid"), - simple_measurements_field("score.fp"), - simple_measurements_field("score.inp"), - simple_measurements_field("score.lcp"), - simple_measurements_field("score.ttfb"), - simple_measurements_field("score.total"), - simple_measurements_field("score.weight.cls"), - simple_measurements_field("score.weight.fcp"), - simple_measurements_field("score.weight.fid"), - simple_measurements_field("score.weight.fp"), - simple_measurements_field("score.weight.inp"), - simple_measurements_field("score.weight.lcp"), - simple_measurements_field("score.weight.ttfb"), - ] -} - - -INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS: dict[Literal["string", "number"], dict[str, str]] = { - "string": { - definition.internal_name: definition.public_alias - for definition in SPAN_COLUMN_DEFINITIONS.values() - if not definition.secondary_alias and definition.search_type == "string" - } - | { - # sentry.service is the project id as a string, but map to project for convenience - "sentry.service": "project", - }, - "number": { - definition.internal_name: definition.public_alias - for definition in SPAN_COLUMN_DEFINITIONS.values() - if not definition.secondary_alias and definition.search_type != "string" - }, -} - - -def translate_internal_to_public_alias( - internal_alias: str, - type: Literal["string", "number"], -) -> str | None: - mappings = INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS.get(type, {}) - return mappings.get(internal_alias) - - -def project_context_constructor(column_name: str) -> Callable[[SnubaParams], VirtualColumnContext]: - def context_constructor(params: SnubaParams) -> VirtualColumnContext: - return VirtualColumnContext( - from_column_name="sentry.project_id", - to_column_name=column_name, - value_map={ - str(project_id): project_name - for project_id, project_name in params.project_id_map.items() - }, - ) - - return context_constructor - - -def device_class_context_constructor(params: SnubaParams) -> VirtualColumnContext: - # EAP defaults to lower case `unknown`, but in querybuilder we used `Unknown` - value_map = {"": "Unknown"} - for device_class, values in DEVICE_CLASS.items(): - for value in values: - value_map[value] = device_class - return VirtualColumnContext( - from_column_name="sentry.device.class", - to_column_name="device.class", - value_map=value_map, - ) - - -def module_context_constructor(params: SnubaParams) -> VirtualColumnContext: - value_map = {key: key for key in SPAN_MODULE_CATEGORY_VALUES} - return VirtualColumnContext( - from_column_name="sentry.category", - to_column_name="span.module", - value_map=value_map, - ) - - -VIRTUAL_CONTEXTS = { - "project": project_context_constructor("project"), - "project.slug": project_context_constructor("project.slug"), - "project.name": project_context_constructor("project.name"), - "device.class": device_class_context_constructor, - "span.module": module_context_constructor, -} - - -SPAN_FUNCTION_DEFINITIONS = { - "sum": FunctionDefinition( - internal_function=Function.FUNCTION_SUM, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "avg": FunctionDefinition( - internal_function=Function.FUNCTION_AVG, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", "percentage", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "avg_sample": FunctionDefinition( - internal_function=Function.FUNCTION_AVG, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", "percentage", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - extrapolation=False, - ), - "count": FunctionDefinition( - internal_function=Function.FUNCTION_COUNT, - infer_search_type_from_arguments=False, - default_search_type="integer", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "count_sample": FunctionDefinition( - internal_function=Function.FUNCTION_COUNT, - infer_search_type_from_arguments=False, - default_search_type="integer", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - extrapolation=False, - ), - "p50": FunctionDefinition( - internal_function=Function.FUNCTION_P50, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "p50_sample": FunctionDefinition( - internal_function=Function.FUNCTION_P50, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - extrapolation=False, - ), - "p75": FunctionDefinition( - internal_function=Function.FUNCTION_P75, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "p90": FunctionDefinition( - internal_function=Function.FUNCTION_P90, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "p95": FunctionDefinition( - internal_function=Function.FUNCTION_P95, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "p99": FunctionDefinition( - internal_function=Function.FUNCTION_P99, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "p100": FunctionDefinition( - internal_function=Function.FUNCTION_MAX, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "max": FunctionDefinition( - internal_function=Function.FUNCTION_MAX, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", "percentage", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "min": FunctionDefinition( - internal_function=Function.FUNCTION_MIN, - default_search_type="duration", - arguments=[ - ArgumentDefinition( - argument_types={"duration", "number", "percentage", *SIZE_TYPE, *DURATION_TYPE}, - default_arg="span.duration", - ) - ], - ), - "count_unique": FunctionDefinition( - internal_function=Function.FUNCTION_UNIQ, - default_search_type="number", - arguments=[ - ArgumentDefinition( - argument_types={"string"}, - ) - ], - ), -} - - -Processors: dict[str, Callable[[Any], Any]] = {} +@dataclass(frozen=True) +class ColumnDefinitions: + functions: dict[str, FunctionDefinition] + columns: dict[str, ResolvedColumn] + contexts: dict[str, Callable[[SnubaParams], VirtualColumnContext]] + trace_item_type: TraceItemType.ValueType diff --git a/src/sentry/search/eap/constants.py b/src/sentry/search/eap/constants.py index 8265878b095d3e..6d9d6d5d74aec6 100644 --- a/src/sentry/search/eap/constants.py +++ b/src/sentry/search/eap/constants.py @@ -4,7 +4,7 @@ from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey from sentry_protos.snuba.v1.trace_item_filter_pb2 import ComparisonFilter -from sentry.search.events.constants import DurationUnit, SizeUnit +from sentry.search.events.constants import DURATION_UNITS, SIZE_UNITS, DurationUnit, SizeUnit OPERATOR_MAP = { "=": ComparisonFilter.OP_EQUALS, @@ -40,6 +40,10 @@ ] ) +SIZE_TYPE: set[SearchType] = set(SIZE_UNITS.keys()) + +DURATION_TYPE: set[SearchType] = set(DURATION_UNITS.keys()) + STRING = AttributeKey.TYPE_STRING BOOLEAN = AttributeKey.TYPE_BOOLEAN FLOAT = AttributeKey.TYPE_FLOAT diff --git a/src/sentry/search/eap/spans.py b/src/sentry/search/eap/resolver.py similarity index 97% rename from src/sentry/search/eap/spans.py rename to src/sentry/search/eap/resolver.py index 3365af25e4733a..ea3c157f153d43 100644 --- a/src/sentry/search/eap/spans.py +++ b/src/sentry/search/eap/resolver.py @@ -12,7 +12,7 @@ AggregationFilter, AggregationOrFilter, ) -from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta, TraceItemType +from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ( AttributeAggregation, AttributeKey, @@ -32,13 +32,7 @@ from sentry.api import event_search from sentry.exceptions import InvalidSearchQuery from sentry.search.eap import constants -from sentry.search.eap.columns import ( - SPAN_COLUMN_DEFINITIONS, - SPAN_FUNCTION_DEFINITIONS, - VIRTUAL_CONTEXTS, - ResolvedColumn, - ResolvedFunction, -) +from sentry.search.eap.columns import ColumnDefinitions, ResolvedColumn, ResolvedFunction from sentry.search.eap.types import SearchResolverConfig from sentry.search.events import constants as qb_constants from sentry.search.events import fields @@ -55,6 +49,7 @@ class SearchResolver: params: SnubaParams config: SearchResolverConfig + definitions: ColumnDefinitions _resolved_attribute_cache: dict[str, tuple[ResolvedColumn, VirtualColumnContext | None]] = ( field(default_factory=dict) ) @@ -75,7 +70,7 @@ def resolve_meta(self, referrer: str) -> RequestMeta: project_ids=self.params.project_ids, start_timestamp=self.params.rpc_start_date, end_timestamp=self.params.rpc_end_date, - trace_item_type=TraceItemType.TRACE_ITEM_TYPE_SPAN, + trace_item_type=self.definitions.trace_item_type, ) @sentry_sdk.trace @@ -525,14 +520,14 @@ def resolve_attribute(self, column: str) -> tuple[ResolvedColumn, VirtualColumnC # If a virtual context is defined the column definition is always the same if column in self._resolved_attribute_cache: return self._resolved_attribute_cache[column] - if column in VIRTUAL_CONTEXTS: - column_context = VIRTUAL_CONTEXTS[column](self.params) + if column in self.definitions.contexts: + column_context = self.definitions.contexts[column](self.params) column_definition = ResolvedColumn( public_alias=column, internal_name=column, search_type="string" ) - elif column in SPAN_COLUMN_DEFINITIONS: + elif column in self.definitions.columns: column_context = None - column_definition = SPAN_COLUMN_DEFINITIONS[column] + column_definition = self.definitions.columns[column] else: if len(column) > qb_constants.MAX_TAG_KEY_LENGTH: raise InvalidSearchQuery( @@ -599,9 +594,9 @@ def resolve_aggregate( alias = match.group("alias") or column # Get the function definition - if function not in SPAN_FUNCTION_DEFINITIONS: + if function not in self.definitions.functions: raise InvalidSearchQuery(f"Unknown function {function}") - function_definition = SPAN_FUNCTION_DEFINITIONS[function] + function_definition = self.definitions.functions[function] parsed_columns = [] diff --git a/src/sentry/search/eap/span_columns.py b/src/sentry/search/eap/span_columns.py new file mode 100644 index 00000000000000..e08f32931da0dc --- /dev/null +++ b/src/sentry/search/eap/span_columns.py @@ -0,0 +1,607 @@ +from collections.abc import Callable +from typing import Literal + +from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType +from sentry_protos.snuba.v1.trace_item_attribute_pb2 import Function, VirtualColumnContext + +from sentry.search.eap import constants +from sentry.search.eap.columns import ( + ArgumentDefinition, + ColumnDefinitions, + FunctionDefinition, + ResolvedColumn, + datetime_processor, + simple_measurements_field, + simple_sentry_field, +) +from sentry.search.events.constants import SPAN_MODULE_CATEGORY_VALUES +from sentry.search.events.types import SnubaParams +from sentry.search.utils import DEVICE_CLASS +from sentry.utils.validators import is_event_id, is_span_id + +SPAN_ATTRIBUTE_DEFINITIONS = { + column.public_alias: column + for column in [ + ResolvedColumn( + public_alias="id", + internal_name="sentry.span_id", + search_type="string", + validator=is_span_id, + ), + ResolvedColumn( + public_alias="parent_span", + internal_name="sentry.parent_span_id", + search_type="string", + validator=is_span_id, + ), + ResolvedColumn( + public_alias="organization.id", + internal_name="sentry.organization_id", + search_type="string", + ), + ResolvedColumn( + public_alias="project.id", + internal_name="sentry.project_id", + internal_type=constants.INT, + search_type="string", + ), + ResolvedColumn( + public_alias="project_id", + internal_name="sentry.project_id", + internal_type=constants.INT, + search_type="string", + secondary_alias=True, + ), + ResolvedColumn( + public_alias="span.action", + internal_name="sentry.action", + search_type="string", + ), + ResolvedColumn( + public_alias="span.description", + internal_name="sentry.name", + search_type="string", + ), + ResolvedColumn( + public_alias="description", + internal_name="sentry.name", + search_type="string", + secondary_alias=True, + ), + # Message maps to description, this is to allow wildcard searching + ResolvedColumn( + public_alias="message", + internal_name="sentry.name", + search_type="string", + secondary_alias=True, + ), + ResolvedColumn( + public_alias="span.domain", + internal_name="sentry.domain", + search_type="string", + ), + ResolvedColumn( + public_alias="span.group", + internal_name="sentry.group", + search_type="string", + ), + ResolvedColumn( + public_alias="span.op", + internal_name="sentry.op", + search_type="string", + ), + ResolvedColumn( + public_alias="span.category", + internal_name="sentry.category", + search_type="string", + ), + ResolvedColumn( + public_alias="span.self_time", + internal_name="sentry.exclusive_time_ms", + search_type="millisecond", + ), + ResolvedColumn( + public_alias="span.duration", + internal_name="sentry.duration_ms", + search_type="millisecond", + ), + ResolvedColumn( + public_alias="span.status", + internal_name="sentry.status", + search_type="string", + ), + ResolvedColumn( + public_alias="span.status_code", + internal_name="sentry.status_code", + search_type="string", + ), + ResolvedColumn( + public_alias="trace", + internal_name="sentry.trace_id", + search_type="string", + validator=is_event_id, + ), + ResolvedColumn( + public_alias="transaction", + internal_name="sentry.segment_name", + search_type="string", + ), + ResolvedColumn( + public_alias="is_transaction", + internal_name="sentry.is_segment", + search_type="boolean", + ), + ResolvedColumn( + public_alias="transaction.span_id", + internal_name="sentry.segment_id", + search_type="string", + ), + ResolvedColumn( + public_alias="profile.id", + internal_name="sentry.profile_id", + search_type="string", + ), + ResolvedColumn( + public_alias="replay.id", + internal_name="sentry.replay_id", + search_type="string", + ), + ResolvedColumn( + public_alias="span.ai.pipeline.group", + internal_name="sentry.ai_pipeline_group", + search_type="string", + ), + ResolvedColumn( + public_alias="ai.total_tokens.used", + internal_name="ai_total_tokens_used", + search_type="number", + ), + ResolvedColumn( + public_alias="ai.total_cost", + internal_name="ai.total_cost", + search_type="number", + ), + ResolvedColumn( + public_alias="http.decoded_response_content_length", + internal_name="http.decoded_response_content_length", + search_type="byte", + ), + ResolvedColumn( + public_alias="http.response_content_length", + internal_name="http.response_content_length", + search_type="byte", + ), + ResolvedColumn( + public_alias="http.response_transfer_size", + internal_name="http.response_transfer_size", + search_type="byte", + ), + ResolvedColumn( + public_alias="sampling_rate", + internal_name="sentry.sampling_factor", + search_type="percentage", + ), + ResolvedColumn( + public_alias="timestamp", + internal_name="sentry.timestamp", + search_type="string", + processor=datetime_processor, + ), + ResolvedColumn( + public_alias="mobile.frames_delay", + internal_name="frames.delay", + search_type="second", + ), + ResolvedColumn( + public_alias="mobile.frames_slow", + internal_name="frames.slow", + search_type="number", + ), + ResolvedColumn( + public_alias="mobile.frames_frozen", + internal_name="frames.frozen", + search_type="number", + ), + ResolvedColumn( + public_alias="mobile.frames_total", + internal_name="frames.total", + search_type="number", + ), + # These fields are extracted from span measurements but were accessed + # 2 ways, with + without the measurements. prefix. So expose both for compatibility. + simple_measurements_field("cache.item_size", search_type="byte", secondary_alias=True), + ResolvedColumn( + public_alias="cache.item_size", + internal_name="cache.item_size", + search_type="byte", + ), + simple_measurements_field( + "messaging.message.body.size", search_type="byte", secondary_alias=True + ), + ResolvedColumn( + public_alias="messaging.message.body.size", + internal_name="messaging.message.body.size", + search_type="byte", + ), + simple_measurements_field( + "messaging.message.receive.latency", search_type="millisecond", secondary_alias=True + ), + ResolvedColumn( + public_alias="messaging.message.receive.latency", + internal_name="messaging.message.receive.latency", + search_type="millisecond", + ), + simple_measurements_field("messaging.message.retry.count", secondary_alias=True), + ResolvedColumn( + public_alias="messaging.message.retry.count", + internal_name="messaging.message.retry.count", + search_type="number", + ), + simple_sentry_field("browser.name"), + simple_sentry_field("environment"), + simple_sentry_field("messaging.destination.name"), + simple_sentry_field("messaging.message.id"), + simple_sentry_field("platform"), + simple_sentry_field("raw_domain"), + simple_sentry_field("release"), + simple_sentry_field("sdk.name"), + simple_sentry_field("sdk.version"), + simple_sentry_field("span_id"), + simple_sentry_field("trace.status"), + simple_sentry_field("transaction.method"), + simple_sentry_field("transaction.op"), + simple_sentry_field("user"), + simple_sentry_field("user.email"), + simple_sentry_field("user.geo.country_code"), + simple_sentry_field("user.geo.subregion"), + simple_sentry_field("user.id"), + simple_sentry_field("user.ip"), + simple_sentry_field("user.username"), + simple_measurements_field("app_start_cold", "millisecond"), + simple_measurements_field("app_start_warm", "millisecond"), + simple_measurements_field("frames_frozen"), + simple_measurements_field("frames_frozen_rate", "percentage"), + simple_measurements_field("frames_slow"), + simple_measurements_field("frames_slow_rate", "percentage"), + simple_measurements_field("frames_total"), + simple_measurements_field("time_to_initial_display", "millisecond"), + simple_measurements_field("time_to_full_display", "millisecond"), + simple_measurements_field("stall_count"), + simple_measurements_field("stall_percentage", "percentage"), + simple_measurements_field("stall_stall_longest_time"), + simple_measurements_field("stall_stall_total_time"), + simple_measurements_field("cls"), + simple_measurements_field("fcp", "millisecond"), + simple_measurements_field("fid", "millisecond"), + simple_measurements_field("fp", "millisecond"), + simple_measurements_field("inp", "millisecond"), + simple_measurements_field("lcp", "millisecond"), + simple_measurements_field("ttfb", "millisecond"), + simple_measurements_field("ttfb.requesttime", "millisecond"), + simple_measurements_field("score.cls"), + simple_measurements_field("score.fcp"), + simple_measurements_field("score.fid"), + simple_measurements_field("score.fp"), + simple_measurements_field("score.inp"), + simple_measurements_field("score.lcp"), + simple_measurements_field("score.ttfb"), + simple_measurements_field("score.total"), + simple_measurements_field("score.weight.cls"), + simple_measurements_field("score.weight.fcp"), + simple_measurements_field("score.weight.fid"), + simple_measurements_field("score.weight.fp"), + simple_measurements_field("score.weight.inp"), + simple_measurements_field("score.weight.lcp"), + simple_measurements_field("score.weight.ttfb"), + ] +} + + +INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS: dict[Literal["string", "number"], dict[str, str]] = { + "string": { + definition.internal_name: definition.public_alias + for definition in SPAN_ATTRIBUTE_DEFINITIONS.values() + if not definition.secondary_alias and definition.search_type == "string" + } + | { + # sentry.service is the project id as a string, but map to project for convenience + "sentry.service": "project", + }, + "number": { + definition.internal_name: definition.public_alias + for definition in SPAN_ATTRIBUTE_DEFINITIONS.values() + if not definition.secondary_alias and definition.search_type != "string" + }, +} + + +def translate_internal_to_public_alias( + internal_alias: str, + type: Literal["string", "number"], +) -> str | None: + mappings = INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS.get(type, {}) + return mappings.get(internal_alias) + + +def project_context_constructor(column_name: str) -> Callable[[SnubaParams], VirtualColumnContext]: + def context_constructor(params: SnubaParams) -> VirtualColumnContext: + return VirtualColumnContext( + from_column_name="sentry.project_id", + to_column_name=column_name, + value_map={ + str(project_id): project_name + for project_id, project_name in params.project_id_map.items() + }, + ) + + return context_constructor + + +def device_class_context_constructor(params: SnubaParams) -> VirtualColumnContext: + # EAP defaults to lower case `unknown`, but in querybuilder we used `Unknown` + value_map = {"": "Unknown"} + for device_class, values in DEVICE_CLASS.items(): + for value in values: + value_map[value] = device_class + return VirtualColumnContext( + from_column_name="sentry.device.class", + to_column_name="device.class", + value_map=value_map, + ) + + +def module_context_constructor(params: SnubaParams) -> VirtualColumnContext: + value_map = {key: key for key in SPAN_MODULE_CATEGORY_VALUES} + return VirtualColumnContext( + from_column_name="sentry.category", + to_column_name="span.module", + value_map=value_map, + ) + + +SPAN_VIRTUAL_CONTEXTS = { + "project": project_context_constructor("project"), + "project.slug": project_context_constructor("project.slug"), + "project.name": project_context_constructor("project.name"), + "device.class": device_class_context_constructor, + "span.module": module_context_constructor, +} + + +SPAN_FUNCTION_DEFINITIONS = { + "sum": FunctionDefinition( + internal_function=Function.FUNCTION_SUM, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "avg": FunctionDefinition( + internal_function=Function.FUNCTION_AVG, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "avg_sample": FunctionDefinition( + internal_function=Function.FUNCTION_AVG, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + extrapolation=False, + ), + "count": FunctionDefinition( + internal_function=Function.FUNCTION_COUNT, + infer_search_type_from_arguments=False, + default_search_type="integer", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "count_sample": FunctionDefinition( + internal_function=Function.FUNCTION_COUNT, + infer_search_type_from_arguments=False, + default_search_type="integer", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + extrapolation=False, + ), + "p50": FunctionDefinition( + internal_function=Function.FUNCTION_P50, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "p50_sample": FunctionDefinition( + internal_function=Function.FUNCTION_P50, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + extrapolation=False, + ), + "p75": FunctionDefinition( + internal_function=Function.FUNCTION_P75, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "p90": FunctionDefinition( + internal_function=Function.FUNCTION_P90, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "p95": FunctionDefinition( + internal_function=Function.FUNCTION_P95, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "p99": FunctionDefinition( + internal_function=Function.FUNCTION_P99, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "p100": FunctionDefinition( + internal_function=Function.FUNCTION_MAX, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "max": FunctionDefinition( + internal_function=Function.FUNCTION_MAX, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "min": FunctionDefinition( + internal_function=Function.FUNCTION_MIN, + default_search_type="duration", + arguments=[ + ArgumentDefinition( + argument_types={ + "duration", + "number", + "percentage", + *constants.SIZE_TYPE, + *constants.DURATION_TYPE, + }, + default_arg="span.duration", + ) + ], + ), + "count_unique": FunctionDefinition( + internal_function=Function.FUNCTION_UNIQ, + default_search_type="number", + arguments=[ + ArgumentDefinition( + argument_types={"string"}, + ) + ], + ), +} + +SPAN_DEFINITIONS = ColumnDefinitions( + functions=SPAN_FUNCTION_DEFINITIONS, + columns=SPAN_ATTRIBUTE_DEFINITIONS, + contexts=SPAN_VIRTUAL_CONTEXTS, + trace_item_type=TraceItemType.TRACE_ITEM_TYPE_SPAN, +) diff --git a/src/sentry/seer/signed_seer_api.py b/src/sentry/seer/signed_seer_api.py index 042fec9d6ca3ef..f70358cf2936bd 100644 --- a/src/sentry/seer/signed_seer_api.py +++ b/src/sentry/seer/signed_seer_api.py @@ -4,7 +4,6 @@ from random import random from typing import Any from urllib.parse import urlparse -from uuid import uuid4 from django.conf import settings from urllib3 import BaseHTTPResponse, HTTPConnectionPool @@ -16,15 +15,19 @@ def make_signed_seer_api_request( - connection_pool: HTTPConnectionPool, path: str, body: bytes, timeout: int | None = None + connection_pool: HTTPConnectionPool, + path: str, + body: bytes, + timeout: int | None = None, ) -> BaseHTTPResponse: host = connection_pool.host if connection_pool.port: host += ":" + str(connection_pool.port) - url, salt = get_seer_salted_url(f"{connection_pool.scheme}://{host}{path}") + url = f"{connection_pool.scheme}://{host}{path}" parsed = urlparse(url) - auth_headers = sign_with_seer_secret(salt, body) + + auth_headers = sign_with_seer_secret(body) timeout_options: dict[str, Any] = {} if timeout: @@ -33,35 +36,25 @@ def make_signed_seer_api_request( with metrics.timer( "seer.request_to_seer", sample_rate=1.0, - # Pull off query params, if any tags={"endpoint": parsed.path}, ): return connection_pool.urlopen( "POST", - parsed.path + "?" + parsed.query, + parsed.path, body=body, headers={"content-type": "application/json;charset=utf-8", **auth_headers}, **timeout_options, ) -def get_seer_salted_url(url: str) -> tuple[str, str]: - if random() < options.get("seer.api.use-nonce-signature"): - salt = uuid4().hex - url += "?nonce=" + salt - else: - salt = url - return url, salt - - -def sign_with_seer_secret(salt: str, body: bytes): +def sign_with_seer_secret(body: bytes) -> dict[str, str]: auth_headers: dict[str, str] = {} if random() < options.get("seer.api.use-shared-secret"): if settings.SEER_API_SHARED_SECRET: - # if random() < options.get("seer.api.use-nonce-signature"): - signature_input = b"%s:%s" % (salt.encode("utf8"), body) signature = hmac.new( - settings.SEER_API_SHARED_SECRET.encode("utf-8"), signature_input, hashlib.sha256 + settings.SEER_API_SHARED_SECRET.encode("utf-8"), + body, + hashlib.sha256, ).hexdigest() auth_headers["Authorization"] = f"Rpcsignature rpc0:{signature}" else: diff --git a/src/sentry/sentry_metrics/use_case_id_registry.py b/src/sentry/sentry_metrics/use_case_id_registry.py index e3bdbb223ca582..12dabd86891a9d 100644 --- a/src/sentry/sentry_metrics/use_case_id_registry.py +++ b/src/sentry/sentry_metrics/use_case_id_registry.py @@ -60,7 +60,6 @@ class UseCaseID(Enum): UseCaseID.SESSIONS, UseCaseID.SPANS, UseCaseID.CUSTOM, - UseCaseID.PROFILES, ) USE_CASE_ID_WRITES_LIMIT_QUOTA_OPTIONS = { diff --git a/src/sentry/signals.py b/src/sentry/signals.py index 51315529f667c2..b2b8dc070b5c11 100644 --- a/src/sentry/signals.py +++ b/src/sentry/signals.py @@ -121,7 +121,6 @@ def _log_robust_failure(self, receiver: object, err: Exception) -> None: first_insight_span_received = BetterSignal() # ["project", "module"] member_invited = BetterSignal() # ["member", "user"] member_joined = BetterSignal() # ["organization_member_id", "organization_id", "user_id"] -issue_tracker_used = BetterSignal() # ["plugin", "project", "user"] plugin_enabled = BetterSignal() # ["plugin", "project", "user"] email_verified = BetterSignal() # ["email"] diff --git a/src/sentry/snuba/spans_rpc.py b/src/sentry/snuba/spans_rpc.py index 3b11a485a88026..ab8cbc6df32b81 100644 --- a/src/sentry/snuba/spans_rpc.py +++ b/src/sentry/snuba/spans_rpc.py @@ -13,7 +13,8 @@ from sentry.exceptions import InvalidSearchQuery from sentry.search.eap.columns import ResolvedColumn, ResolvedFunction from sentry.search.eap.constants import MAX_ROLLUP_POINTS, VALID_GRANULARITIES -from sentry.search.eap.spans import SearchResolver +from sentry.search.eap.resolver import SearchResolver +from sentry.search.eap.span_columns import SPAN_DEFINITIONS from sentry.search.eap.types import CONFIDENCES, ConfidenceData, EAPResponse, SearchResolverConfig from sentry.search.events.fields import get_function_alias, is_function from sentry.search.events.types import EventsMeta, SnubaData, SnubaParams @@ -31,6 +32,14 @@ def categorize_column(column: ResolvedColumn | ResolvedFunction) -> Column: return Column(key=column.proto_definition, label=column.public_alias) +def get_resolver(params: SnubaParams, config: SearchResolverConfig) -> SearchResolver: + return SearchResolver( + params=params, + config=config, + definitions=SPAN_DEFINITIONS, + ) + + @sentry_sdk.trace def run_table_query( params: SnubaParams, @@ -45,7 +54,7 @@ def run_table_query( ) -> EAPResponse: """Make the query""" resolver = ( - SearchResolver(params=params, config=config) if search_resolver is None else search_resolver + get_resolver(params=params, config=config) if search_resolver is None else search_resolver ) meta = resolver.resolve_meta(referrer=referrer) where, having, query_contexts = resolver.resolve_query(query_string) @@ -151,7 +160,7 @@ def get_timeseries_query( granularity_secs: int, extra_conditions: TraceItemFilter | None = None, ) -> TimeSeriesRequest: - resolver = SearchResolver(params=params, config=config) + resolver = get_resolver(params=params, config=config) meta = resolver.resolve_meta(referrer=referrer) query, _, query_contexts = resolver.resolve_query(query_string) (aggregations, _) = resolver.resolve_aggregates(y_axes) @@ -328,7 +337,7 @@ def run_top_events_timeseries_query( change this""" """Make a table query first to get what we need to filter by""" validate_granularity(params, granularity_secs) - search_resolver = SearchResolver(params, config) + search_resolver = get_resolver(params, config) top_events = run_table_query( params, query_string, diff --git a/src/sentry/tasks/auto_source_code_config.py b/src/sentry/tasks/auto_source_code_config.py index 02c60c94a10e42..5193ed2ae50047 100644 --- a/src/sentry/tasks/auto_source_code_config.py +++ b/src/sentry/tasks/auto_source_code_config.py @@ -39,7 +39,7 @@ class DeriveCodeMappingsErrorReason(StrEnum): EMPTY_TREES = "The trees are empty." -def process_error(error: ApiError, extra: dict[str, str]) -> None: +def process_error(error: ApiError, extra: dict[str, Any]) -> None: """Log known issues and report unknown ones""" if error.json: json_data: Any = error.json @@ -79,24 +79,15 @@ def process_error(error: ApiError, extra: dict[str, str]) -> None: ) -# XXX: To be deleted after queue is empty -@instrumented_task( - name="sentry.tasks.derive_code_mappings.derive_code_mappings", - queue="derive_code_mappings", - default_retry_delay=60 * 10, - max_retries=3, -) -def derive_code_mappings(project_id: int, event_id: str, **kwargs: Any) -> None: - auto_source_code_config(project_id, event_id=event_id, **kwargs) - - @instrumented_task( name="sentry.tasks.auto_source_code_config", queue="auto_source_code_config", default_retry_delay=60 * 10, max_retries=3, ) -def auto_source_code_config(project_id: int, event_id: str, **kwargs: Any) -> None: +def auto_source_code_config( + project_id: int, event_id: str, group_id: int | None = None, **kwargs: Any +) -> None: """ Process errors for customers with source code management installed and calculate code mappings among other things. @@ -104,19 +95,27 @@ def auto_source_code_config(project_id: int, event_id: str, **kwargs: Any) -> No This task is queued at most once per hour per project. """ project = Project.objects.get(id=project_id) - org: Organization = Organization.objects.get(id=project.organization_id) + org = Organization.objects.get(id=project.organization_id) set_tag("organization.slug", org.slug) # When you look at the performance page the user is a default column set_user({"username": org.slug}) set_tag("project.slug", project.slug) - extra: dict[str, Any] = {"organization.slug": org.slug, "event_id": event_id} - - event = eventstore.backend.get_event_by_id(project_id, event_id) + extra = { + "organization.slug": org.slug, + "project_id": project_id, + "group_id": group_id, + "event_id": event_id, + } + + if group_id is None: + event = eventstore.backend.get_event_by_id(project_id, event_id) + else: + event = eventstore.backend.get_event_by_id(project_id, event_id, group_id) if event is None: - logger.error("Event not found.", extra={"project_id": project_id, "event_id": event_id}) + logger.error("Event not found.", extra=extra) return - stacktrace_paths: list[str] = identify_stacktrace_paths(event.data) + stacktrace_paths = identify_stacktrace_paths(event.data) if not stacktrace_paths: logger.info("No stacktrace paths found.", extra=extra) return @@ -144,7 +143,7 @@ def auto_source_code_config(project_id: int, event_id: str, **kwargs: Any) -> No lifecycle.record_halt(error, extra) return except UnableToAcquireLock as error: - extra["error"] = error + extra["error"] = str(error) lifecycle.record_failure(error, extra) return except Exception: diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 984a32a1dbaf2c..3b97a990f1742c 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -995,7 +995,7 @@ def process_code_mappings(job: PostProcessJob) -> None: return from sentry.issues.auto_source_code_config.code_mapping import SUPPORTED_LANGUAGES - from sentry.tasks.auto_source_code_config import auto_source_code_config, derive_code_mappings + from sentry.tasks.auto_source_code_config import auto_source_code_config try: event = job["event"] @@ -1017,13 +1017,10 @@ def process_code_mappings(job: PostProcessJob) -> None: else: return - if options.get("system.new-auto-source-code-config-queue"): - auto_source_code_config.delay(project.id, event_id=event.event_id) - else: - derive_code_mappings.delay(project.id, event_id=event.event_id) + auto_source_code_config.delay(project.id, event_id=event.event_id, group_id=group_id) except Exception: - logger.exception("derive_code_mappings: Failed to process code mappings") + logger.exception("Failed to process automatic source code config") def process_commits(job: PostProcessJob) -> None: diff --git a/src/sentry/tempest/tasks.py b/src/sentry/tempest/tasks.py index 4748680c131db9..8a73dcfdd64c4a 100644 --- a/src/sentry/tempest/tasks.py +++ b/src/sentry/tempest/tasks.py @@ -1,9 +1,9 @@ import logging +import requests from django.conf import settings from requests import Response -from sentry import http from sentry.models.projectkey import ProjectKey, UseCase from sentry.silo.base import SiloMode from sentry.tasks.base import instrumented_task @@ -188,12 +188,21 @@ def fetch_latest_id_from_tempest( "client_secret": client_secret, } - response = http.safe_urlopen( + response = requests.post( url=settings.SENTRY_TEMPEST_URL + "/latest-id", - method="POST", headers={"Content-Type": "application/json"}, json=payload, ) + + logger.info( + "Tempest API response", + extra={ + "status_code": response.status_code, + "response_text": response.text, + "endpoint": "/latest-id", + }, + ) + return response @@ -219,11 +228,20 @@ def fetch_items_from_tempest( "attach_screenshot": attach_screenshot, } - response = http.safe_urlopen( + response = requests.post( url=settings.SENTRY_TEMPEST_URL + "/crashes", - method="POST", headers={"Content-Type": "application/json"}, json=payload, timeout=time_out, ) + + logger.info( + "Tempest API response", + extra={ + "status_code": response.status_code, + "response_text": response.text, + "endpoint": "/crashes", + }, + ) + return response diff --git a/src/sentry/templates/sentry/partial/preload-data.html b/src/sentry/templates/sentry/partial/preload-data.html index 1fc757cb24923b..bebe3db5c44e15 100644 --- a/src/sentry/templates/sentry/partial/preload-data.html +++ b/src/sentry/templates/sentry/partial/preload-data.html @@ -16,30 +16,39 @@ } var host = ''; if (window.__initialData.links && window.__initialData.links.regionUrl !== window.__initialData.links.sentryUrl) { - var host = window.__initialData.links.regionUrl; + host = window.__initialData.links.regionUrl; } - function promiseRequest(url) { - return new Promise(function (resolve, reject) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', url); - xhr.setRequestHeader("sentry-trace", window.__initialData.initialTrace.sentry_trace); - xhr.setRequestHeader("baggage", window.__initialData.initialTrace.baggage); - xhr.withCredentials = true; - xhr.onload = function () { - try { - this.status >= 200 && this.status < 300 - ? resolve([JSON.parse(xhr.response), this.statusText, xhr]) - : reject([this.status, this.statusText]); - } catch (e) { - reject(); - } - }; - xhr.onerror = function () { - reject([this.status, this.statusText]); - }; - xhr.send(); - }); + async function promiseRequest(url) { + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json; charset=utf-8', + 'Content-Type': 'application/json', + 'sentry-trace': window.__initialData.initialTrace.sentry_trace, + 'baggage': window.__initialData.initialTrace.baggage, + }, + credentials: 'include', + priority: 'high', + }); + if (response.status >= 200 && response.status < 300) { + const text = await response.text(); + const json = JSON.parse(text); + // Matching ResponseMeta from api + const responseMeta = { + status: response.status, + statusText: response.statusText, + responseJSON: json, + responseText: text, + getResponseHeader: (header) => response.headers.get(header), + }; + return [json, response.statusText, responseMeta]; + } + throw [response.status, response.statusText]; + } catch (error) { + throw [error.status, error.statusText]; + } } function makeUrl(suffix) { diff --git a/src/sentry/uptime/migrations/0022_add_trace_sampling_to_uptime_monitors.py b/src/sentry/uptime/migrations/0022_add_trace_sampling_to_uptime_monitors.py new file mode 100644 index 00000000000000..03b2a72cd6b609 --- /dev/null +++ b/src/sentry/uptime/migrations/0022_add_trace_sampling_to_uptime_monitors.py @@ -0,0 +1,57 @@ +# Generated by Django 5.1.5 on 2025-01-21 18:13 + +import django.db.models.functions.comparison +import django.db.models.functions.text +from django.db import migrations, models + +from sentry.new_migrations.migrations import CheckedMigration +from sentry.new_migrations.monkey.special import SafeRunSQL + + +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 = [ + ("uptime", "0021_drop_region_table_col"), + ] + + operations = [ + migrations.AddConstraint( + model_name="uptimesubscription", + constraint=models.UniqueConstraint( + models.F("url"), + models.F("interval_seconds"), + models.F("timeout_ms"), + models.F("method"), + models.F("trace_sampling"), + django.db.models.functions.text.MD5("headers"), + django.db.models.functions.comparison.Coalesce( + django.db.models.functions.text.MD5("body"), models.Value("") + ), + name="uptime_uptimesubscription_unique_subscription_check_3", + ), + ), + migrations.RemoveConstraint( + model_name="uptimesubscription", + name="uptime_uptimesubscription_unique_subscription_check", + ), + # XXX(epurkhiser): This is left-over from a failed mgration during + # INC-941. See https://github.com/getsentry/sentry/pull/80876 + # + # This was never cleaned up so we're doing that here + SafeRunSQL( + "DROP INDEX CONCURRENTLY IF EXISTS uptime_uptimesubscription_unique_subscription_check_2" + ), + ] diff --git a/src/sentry/uptime/models.py b/src/sentry/uptime/models.py index 77e4ca0cb745e8..f18410161615a7 100644 --- a/src/sentry/uptime/models.py +++ b/src/sentry/uptime/models.py @@ -102,9 +102,10 @@ class Meta: "interval_seconds", "timeout_ms", "method", + "trace_sampling", MD5("headers"), Coalesce(MD5("body"), Value("")), - name="uptime_uptimesubscription_unique_subscription_check", + name="uptime_uptimesubscription_unique_subscription_check_3", ), ] diff --git a/src/sentry/utils/demo_mode.py b/src/sentry/utils/demo_mode.py new file mode 100644 index 00000000000000..77b0fa9b537dcc --- /dev/null +++ b/src/sentry/utils/demo_mode.py @@ -0,0 +1,49 @@ +from sentry import options +from sentry.models.organization import Organization +from sentry.users.models.user import User + +READONLY_SCOPES = frozenset( + [ + "project:read", + "org:read", + "event:read", + "member:read", + "team:read", + "project:releases", + "alerts:read", + ] +) + + +def is_readonly_user(user: User | None) -> bool: + if not options.get("demo-mode.enabled"): + return False + + if not user: + return False + + email = getattr(user, "email", None) + + return email in options.get("demo-mode.users") + + +def is_demo_org(organization: Organization | None): + if not options.get("demo-mode.enabled"): + return False + + if not organization: + return False + + return organization.id in options.get("demo-mode.orgs") + + +def get_readonly_user(): + if not options.get("demo-mode.enabled"): + return None + + email = options.get("demo-mode.users")[0] + return User.objects.get(email=email) + + +def get_readonly_scopes() -> frozenset[str]: + return READONLY_SCOPES diff --git a/src/sentry/web/frontend/auth_login.py b/src/sentry/web/frontend/auth_login.py index cf19698091d73c..c452ed25803144 100644 --- a/src/sentry/web/frontend/auth_login.py +++ b/src/sentry/web/frontend/auth_login.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import urllib from typing import Any from django.conf import settings @@ -668,6 +669,24 @@ def handle_basic_auth(self, request: Request, **kwargs) -> HttpResponseBase: if not user.is_active: return self.redirect(reverse("sentry-reactivate-account")) if organization: + # Check if the user is a member of the provided organization based on their email + membership = organization_service.check_membership_by_email( + email=user.email, organization_id=organization.id + ) + + invitation_link = getattr(membership, "invitation_link", None) + + # If the user is a member, the user_id is None, and they are in a "pending invite acceptance" state with a valid invitation link, + # we redirect them to the invitation page to explicitly accept the invite + if ( + membership + and membership.user_id is None + and membership.is_pending + and invitation_link + ): + accept_path = urllib.parse.urlparse(invitation_link).path + return self.redirect(accept_path) + # Refresh the organization we fetched prior to login in order to check its login state. org_context = organization_service.get_organization_by_slug( user_id=request.user.id, diff --git a/src/sentry/workflow_engine/handlers/condition/__init__.py b/src/sentry/workflow_engine/handlers/condition/__init__.py index 68066f7706952f..b45e6a7a66494b 100644 --- a/src/sentry/workflow_engine/handlers/condition/__init__.py +++ b/src/sentry/workflow_engine/handlers/condition/__init__.py @@ -1,5 +1,7 @@ __all__ = [ "EventCreatedByDetectorConditionHandler", + "EventFrequencyCountHandler", + "EventFrequencyPercentHandler", "EventSeenCountConditionHandler", "EveryEventConditionHandler", "ReappearedEventConditionHandler", @@ -23,6 +25,7 @@ from .assigned_to_handler import AssignedToConditionHandler from .event_attribute_handler import EventAttributeConditionHandler from .event_created_by_detector_handler import EventCreatedByDetectorConditionHandler +from .event_frequency_handlers import EventFrequencyCountHandler, EventFrequencyPercentHandler from .event_seen_count_handler import EventSeenCountConditionHandler from .every_event_handler import EveryEventConditionHandler from .existing_high_priority_issue_handler import ExistingHighPriorityIssueConditionHandler diff --git a/src/sentry/workflow_engine/handlers/condition/event_frequency_handlers.py b/src/sentry/workflow_engine/handlers/condition/event_frequency_handlers.py index dd8c93dbd9c34a..30a067b8a7a436 100644 --- a/src/sentry/workflow_engine/handlers/condition/event_frequency_handlers.py +++ b/src/sentry/workflow_engine/handlers/condition/event_frequency_handlers.py @@ -16,7 +16,7 @@ ) from sentry.workflow_engine.models.data_condition import Condition from sentry.workflow_engine.registry import condition_handler_registry -from sentry.workflow_engine.types import DataConditionHandler, DataConditionResult +from sentry.workflow_engine.types import DataConditionHandler, DataConditionResult, WorkflowJob class EventFrequencyConditionHandler(BaseEventFrequencyConditionHandler): @@ -59,7 +59,7 @@ def get_result(model: TSDBModel, group_ids: list[int]) -> dict[int, int]: @condition_handler_registry.register(Condition.EVENT_FREQUENCY_COUNT) -class EventFrequencyCountHandler(EventFrequencyConditionHandler, DataConditionHandler[int]): +class EventFrequencyCountHandler(EventFrequencyConditionHandler, DataConditionHandler[WorkflowJob]): comparison_json_schema = { "type": "object", "properties": { @@ -71,12 +71,16 @@ class EventFrequencyCountHandler(EventFrequencyConditionHandler, DataConditionHa } @staticmethod - def evaluate_value(value: int, comparison: Any) -> DataConditionResult: - return value > comparison["value"] + def evaluate_value(value: WorkflowJob, comparison: Any) -> DataConditionResult: + if len(value.get("snuba_results", [])) != 1: + return False + return value["snuba_results"][0] > comparison["value"] @condition_handler_registry.register(Condition.EVENT_FREQUENCY_PERCENT) -class EventFrequencyPercentHandler(EventFrequencyConditionHandler, DataConditionHandler[list[int]]): +class EventFrequencyPercentHandler( + EventFrequencyConditionHandler, DataConditionHandler[WorkflowJob] +): comparison_json_schema = { "type": "object", "properties": { @@ -89,7 +93,10 @@ class EventFrequencyPercentHandler(EventFrequencyConditionHandler, DataCondition } @staticmethod - def evaluate_value(value: list[int], comparison: Any) -> DataConditionResult: - if len(value) != 2: + def evaluate_value(value: WorkflowJob, comparison: Any) -> DataConditionResult: + if len(value.get("snuba_results", [])) != 2: return False - return percent_increase(value[0], value[1]) > comparison["value"] + return ( + percent_increase(value["snuba_results"][0], value["snuba_results"][1]) + > comparison["value"] + ) diff --git a/src/sentry/workflow_engine/migration_helpers/alert_rule.py b/src/sentry/workflow_engine/migration_helpers/alert_rule.py index 5f186620d4cad0..d0b4c08c5cb8e0 100644 --- a/src/sentry/workflow_engine/migration_helpers/alert_rule.py +++ b/src/sentry/workflow_engine/migration_helpers/alert_rule.py @@ -122,14 +122,15 @@ def migrate_metric_data_conditions( if alert_rule.threshold_type == AlertRuleThresholdType.ABOVE.value else Condition.LESS ) + condition_result = ( + DetectorPriorityLevel.MEDIUM + if alert_rule_trigger.label == "warning" + else DetectorPriorityLevel.HIGH + ) detector_trigger = DataCondition.objects.create( comparison=alert_rule_trigger.alert_threshold, - condition_result=( - DetectorPriorityLevel.MEDIUM - if alert_rule_trigger.label == "warning" - else DetectorPriorityLevel.HIGH - ), + condition_result=condition_result, type=threshold_type, condition_group=detector_data_condition_group, ) @@ -147,11 +148,7 @@ def migrate_metric_data_conditions( workflow=alert_rule_workflow.workflow, ) data_condition = DataCondition.objects.create( - comparison=( - DetectorPriorityLevel.MEDIUM - if alert_rule_trigger.label == "warning" - else DetectorPriorityLevel.HIGH - ), + comparison=condition_result, condition_result=True, type=Condition.ISSUE_PRIORITY_EQUALS, condition_group=data_condition_group, diff --git a/src/sentry/workflow_engine/models/data_condition.py b/src/sentry/workflow_engine/models/data_condition.py index 1a364f21b8d507..3788527647c7a2 100644 --- a/src/sentry/workflow_engine/models/data_condition.py +++ b/src/sentry/workflow_engine/models/data_condition.py @@ -66,6 +66,18 @@ class Condition(models.TextChoices): Condition.NOT_EQUAL: operator.ne, } +SLOW_CONDITIONS = [ + Condition.EVENT_FREQUENCY_COUNT, + Condition.EVENT_FREQUENCY_PERCENT, + Condition.EVENT_UNIQUE_USER_FREQUENCY_COUNT, + Condition.EVENT_UNIQUE_USER_FREQUENCY_PERCENT, + Condition.PERCENT_SESSIONS_COUNT, + Condition.PERCENT_SESSIONS_PERCENT, + Condition.EVENT_UNIQUE_USER_FREQUENCY_WITH_CONDITIONS_COUNT, + Condition.EVENT_UNIQUE_USER_FREQUENCY_WITH_CONDITIONS_PERCENT, +] + + T = TypeVar("T") @@ -140,18 +152,6 @@ def evaluate_value(self, value: T) -> DataConditionResult: return self.get_condition_result() if result else None -SLOW_CONDITIONS = [ - Condition.EVENT_FREQUENCY_COUNT, - Condition.EVENT_FREQUENCY_PERCENT, - Condition.EVENT_UNIQUE_USER_FREQUENCY_COUNT, - Condition.EVENT_UNIQUE_USER_FREQUENCY_PERCENT, - Condition.PERCENT_SESSIONS_COUNT, - Condition.PERCENT_SESSIONS_PERCENT, - Condition.EVENT_UNIQUE_USER_FREQUENCY_WITH_CONDITIONS_COUNT, - Condition.EVENT_UNIQUE_USER_FREQUENCY_WITH_CONDITIONS_PERCENT, -] - - def is_slow_condition(cond: DataCondition) -> bool: return Condition(cond.type) in SLOW_CONDITIONS diff --git a/src/sentry/workflow_engine/models/workflow.py b/src/sentry/workflow_engine/models/workflow.py index cdd7791788b7a0..367edc6d435ba8 100644 --- a/src/sentry/workflow_engine/models/workflow.py +++ b/src/sentry/workflow_engine/models/workflow.py @@ -9,6 +9,7 @@ from sentry.db.models import DefaultFieldsModel, FlexibleForeignKey, region_silo_model, sane_repr from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey from sentry.models.owner_base import OwnerModel +from sentry.workflow_engine.models.data_condition import DataCondition, is_slow_condition from sentry.workflow_engine.processors.data_condition_group import evaluate_condition_group from sentry.workflow_engine.types import WorkflowJob @@ -79,6 +80,18 @@ def evaluate_trigger_conditions(self, job: WorkflowJob) -> bool: return evaluation +def get_slow_conditions(workflow: Workflow) -> list[DataCondition]: + if not workflow.when_condition_group: + return [] + + slow_conditions = [ + condition + for condition in workflow.when_condition_group.conditions.all() + if is_slow_condition(condition) + ] + return slow_conditions + + @receiver(pre_save, sender=Workflow) def enforce_config_schema(sender, instance: Workflow, **kwargs): instance.validate_config(instance.config_schema) diff --git a/src/sentry/workflow_engine/processors/data_condition_group.py b/src/sentry/workflow_engine/processors/data_condition_group.py index 637e91c5a6b34d..788836eae28b33 100644 --- a/src/sentry/workflow_engine/processors/data_condition_group.py +++ b/src/sentry/workflow_engine/processors/data_condition_group.py @@ -28,11 +28,6 @@ def evaluate_condition_group( results = [] conditions = get_data_conditions_for_group(data_condition_group.id) - # TODO - @saponifi3d - # Split the conditions into fast and slow conditions - # Evaluate the fast conditions first, if any are met, return early - # Enqueue the slow conditions to be evaluated later - if len(conditions) == 0: # if we don't have any conditions, always return True return True, [] @@ -54,12 +49,14 @@ def evaluate_condition_group( if data_condition_group.logic_type == data_condition_group.Type.NONE: # if we get to this point, no conditions were met return True, [] + elif data_condition_group.logic_type == data_condition_group.Type.ANY: is_any_condition_met = any([result[0] for result in results]) if is_any_condition_met: condition_results = [result[1] for result in results if result[0]] return is_any_condition_met, condition_results + elif data_condition_group.logic_type == data_condition_group.Type.ALL: conditions_met = [result[0] for result in results] is_all_conditions_met = all(conditions_met) diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index be2fe0f88e4e92..1baab325c4ae27 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -1,22 +1,83 @@ import logging +from collections import defaultdict import sentry_sdk -from sentry.utils import metrics -from sentry.workflow_engine.models import Detector, Workflow +from sentry import buffer +from sentry.utils import json, metrics +from sentry.workflow_engine.models import Detector, Workflow, WorkflowDataConditionGroup +from sentry.workflow_engine.models.workflow import get_slow_conditions from sentry.workflow_engine.processors.action import evaluate_workflow_action_filters +from sentry.workflow_engine.processors.data_condition_group import evaluate_condition_group from sentry.workflow_engine.processors.detector import get_detector_by_event from sentry.workflow_engine.types import WorkflowJob logger = logging.getLogger(__name__) +WORKFLOW_ENGINE_BUFFER_LIST_KEY = "workflow_engine_delayed_processing_buffer" + + +def get_data_condition_groups_to_fire( + workflows: set[Workflow], job: WorkflowJob +) -> dict[int, list[int]]: + workflow_action_groups: dict[int, list[int]] = defaultdict(list) + + workflow_ids = {workflow.id for workflow in workflows} + + workflow_dcgs = WorkflowDataConditionGroup.objects.filter( + workflow_id__in=workflow_ids + ).select_related("condition_group", "workflow") + + for workflow_dcg in workflow_dcgs: + action_condition = workflow_dcg.condition_group + evaluation, result = evaluate_condition_group(action_condition, job) + + if evaluation: + workflow_action_groups[workflow_dcg.workflow_id].append(action_condition.id) + + return workflow_action_groups + + +def enqueue_workflows( + workflows: set[Workflow], + job: WorkflowJob, +) -> None: + event = job["event"] + project_id = event.group.project.id + workflow_action_groups = get_data_condition_groups_to_fire(workflows, job) + + for workflow in workflows: + buffer.backend.push_to_sorted_set(key=WORKFLOW_ENGINE_BUFFER_LIST_KEY, value=project_id) + + action_filters = workflow_action_groups.get(workflow.id, []) + if not action_filters: + continue + + action_filter_fields = ":".join(map(str, action_filters)) + + value = json.dumps({"event_id": event.event_id, "occurrence_id": event.occurrence_id}) + buffer.backend.push_to_hash( + model=Workflow, + filters={"project": project_id}, + field=f"{workflow.id}:{event.group.id}:{action_filter_fields}", + value=value, + ) + def evaluate_workflow_triggers(workflows: set[Workflow], job: WorkflowJob) -> set[Workflow]: triggered_workflows: set[Workflow] = set() + workflows_to_enqueue: set[Workflow] = set() for workflow in workflows: if workflow.evaluate_trigger_conditions(job): triggered_workflows.add(workflow) + else: + if get_slow_conditions(workflow): + # enqueue to be evaluated later + workflows_to_enqueue.add(workflow) + + if workflows_to_enqueue: + enqueue_workflows(workflows_to_enqueue, job) return triggered_workflows diff --git a/src/sentry/workflow_engine/types.py b/src/sentry/workflow_engine/types.py index c569455d5815be..b8200ab5a217df 100644 --- a/src/sentry/workflow_engine/types.py +++ b/src/sentry/workflow_engine/types.py @@ -46,6 +46,7 @@ class WorkflowJob(EventJob, total=False): has_alert: bool has_escalated: bool workflow: Workflow + snuba_results: list[int] # TODO - @saponifi3 / TODO(cathy): audit this class ActionHandler: diff --git a/src/sentry_plugins/redmine/plugin.py b/src/sentry_plugins/redmine/plugin.py index a66d389ecd1e1b..6a441a983685a8 100644 --- a/src/sentry_plugins/redmine/plugin.py +++ b/src/sentry_plugins/redmine/plugin.py @@ -8,6 +8,7 @@ from sentry.utils import json from sentry.utils.http import absolute_uri from sentry_plugins.base import CorePluginMixin +from sentry_plugins.utils import get_secret_field_config from .client import RedmineClient from .forms import RedmineNewIssueForm @@ -113,7 +114,7 @@ def get_issue_url(self, group, issue_id: str) -> str: host = self.get_option("host", group.project) return "{}/issues/{}".format(host.rstrip("/"), issue_id) - def build_config(self): + def build_config(self, project): host = { "name": "host", "label": "Host", @@ -121,13 +122,13 @@ def build_config(self): "help": "e.g. http://bugs.redmine.org", "required": True, } - key = { - "name": "key", - "label": "Key", - "type": "text", - "help": "Your API key is available on your account page after enabling the Rest API (Administration -> Settings -> Authentication)", - "required": True, - } + key = get_secret_field_config( + name="key", + label="Key", + secret=self.get_option("key", project), + help="Your API key is available on your account page after enabling the Rest API (Administration -> Settings -> Authentication)", + required=True, + ) project_id = { "name": "project_id", "label": "Project*", @@ -182,7 +183,7 @@ def build_initial(self, initial_args, project): def get_config(self, project, user=None, initial=None, add_additional_fields: bool = False): self.client_errors = [] - self.fields = self.build_config() + self.fields = self.build_config(project) initial_args = initial or {} initial = self.build_initial(initial_args, project) diff --git a/src/sentry_plugins/sessionstack/plugin.py b/src/sentry_plugins/sessionstack/plugin.py index f0a95ee0659f88..7015a56b5f41ce 100644 --- a/src/sentry_plugins/sessionstack/plugin.py +++ b/src/sentry_plugins/sessionstack/plugin.py @@ -10,6 +10,7 @@ from sentry.plugins.base.v2 import EventPreprocessor, Plugin2 from sentry.utils.settings import is_self_hosted from sentry_plugins.base import CorePluginMixin +from sentry_plugins.utils import get_secret_field_config from .client import InvalidApiUrlError, InvalidWebsiteIdError, SessionStackClient, UnauthorizedError @@ -84,7 +85,6 @@ def validate_config(self, project, config, actor=None): def get_config(self, project, user=None, initial=None, add_additional_fields: bool = False): account_email = self.get_option("account_email", project) - api_token = self.get_option("api_token", project) website_id = self.get_option("website_id", project) api_url = self.get_option("api_url", project) player_url = self.get_option("player_url", project) @@ -98,14 +98,13 @@ def get_config(self, project, user=None, initial=None, add_additional_fields: bo "placeholder": 'e.g. "user@example.com"', "required": True, }, - { - "name": "api_token", - "label": "API Token", - "default": api_token, - "type": "text", - "help": "SessionStack generated API token.", - "required": True, - }, + get_secret_field_config( + name="api_token", + label="API Token", + secret=self.get_option("api_token", project), + help="SessionStack generated API token.", + required=True, + ), { "name": "website_id", "label": "Website ID", diff --git a/static/app/actionCreators/group.tsx b/static/app/actionCreators/group.tsx index 00f465f02ad8ff..41f67468250a17 100644 --- a/static/app/actionCreators/group.tsx +++ b/static/app/actionCreators/group.tsx @@ -4,64 +4,19 @@ import type {RequestCallbacks, RequestOptions} from 'sentry/api'; import {Client} from 'sentry/api'; import GroupStore from 'sentry/stores/groupStore'; import type {Actor} from 'sentry/types/core'; -import type {Group, Note, Tag as GroupTag, TagValue} from 'sentry/types/group'; -import type {Member} from 'sentry/types/organization'; -import type {User} from 'sentry/types/user'; +import type {Group, Tag as GroupTag, TagValue} from 'sentry/types/group'; import {buildTeamId, buildUserId} from 'sentry/utils'; import {uniqueId} from 'sentry/utils/guid'; import type {ApiQueryKey, UseApiQueryOptions} from 'sentry/utils/queryClient'; import {useApiQuery} from 'sentry/utils/queryClient'; type AssignedBy = 'suggested_assignee' | 'assignee_selector'; -type AssignToUserParams = { - assignedBy: AssignedBy; - /** - * Issue id - */ - id: string; - orgSlug: string; - user: User | Actor; - member?: Member; -}; - -export function assignToUser(params: AssignToUserParams) { - const api = new Client(); - - const endpoint = `/organizations/${params.orgSlug}/issues/${params.id}/`; - - const id = uniqueId(); - - GroupStore.onAssignTo(id, params.id, { - email: params.member?.email ?? '', - }); - - const request = api.requestPromise(endpoint, { - method: 'PUT', - // Sending an empty value to assignedTo is the same as "clear", - // so if no member exists, that implies that we want to clear the - // current assignee. - data: { - assignedTo: params.user ? buildUserId(params.user.id) : '', - assignedBy: params.assignedBy, - }, - }); - - request - .then(data => { - GroupStore.onAssignToSuccess(id, params.id, data); - }) - .catch(data => { - GroupStore.onAssignToError(id, params.id, data); - }); - - return request; -} export function clearAssignment( groupId: string, orgSlug: string, assignedBy: AssignedBy -) { +): Promise { const api = new Client(); const endpoint = `/organizations/${orgSlug}/issues/${groupId}/`; @@ -84,9 +39,11 @@ export function clearAssignment( request .then(data => { GroupStore.onAssignToSuccess(id, groupId, data); + return data; }) .catch(data => { GroupStore.onAssignToError(id, groupId, data); + throw data; }); return request; @@ -102,7 +59,12 @@ type AssignToActorParams = { orgSlug: string; }; -export function assignToActor({id, actor, assignedBy, orgSlug}: AssignToActorParams) { +export function assignToActor({ + id, + actor, + assignedBy, + orgSlug, +}: AssignToActorParams): Promise { const api = new Client(); const endpoint = `/organizations/${orgSlug}/issues/${id}/`; @@ -135,76 +97,14 @@ export function assignToActor({id, actor, assignedBy, orgSlug}: AssignToActorPar }) .then(data => { GroupStore.onAssignToSuccess(guid, id, data); + return data; }) .catch(data => { GroupStore.onAssignToSuccess(guid, id, data); + throw data; }); } -export function deleteNote( - api: Client, - orgSlug: string, - group: Group, - id: string, - _oldText: string -) { - const restore = group.activity.find(activity => activity.id === id); - const index = GroupStore.removeActivity(group.id, id); - - if (index === -1 || restore === undefined) { - // I dunno, the id wasn't found in the GroupStore - return Promise.reject(new Error('Group was not found in store')); - } - - const promise = api.requestPromise( - `/organizations/${orgSlug}/issues/${group.id}/comments/${id}/`, - { - method: 'DELETE', - } - ); - - promise.catch(() => GroupStore.addActivity(group.id, restore, index)); - - return promise; -} - -export function createNote(api: Client, orgSlug: string, group: Group, note: Note) { - const promise = api.requestPromise( - `/organizations/${orgSlug}/issues/${group.id}/comments/`, - { - method: 'POST', - data: note, - } - ); - - promise.then(data => GroupStore.addActivity(group.id, data)); - - return promise; -} - -export function updateNote( - api: Client, - orgSlug: string, - group: Group, - note: Note, - id: string, - oldText: string -) { - GroupStore.updateActivity(group.id, id, {text: note.text}); - - const promise = api.requestPromise( - `/organizations/${orgSlug}/issues/${group.id}/comments/${id}/`, - { - method: 'PUT', - data: note, - } - ); - - promise.catch(() => GroupStore.updateActivity(group.id, id, {text: oldText})); - - return promise; -} - type ParamsType = { environment?: string | string[] | null; itemIds?: string[]; diff --git a/static/app/actionCreators/organization.tsx b/static/app/actionCreators/organization.tsx index ecdf88af217608..7243b7a8c51657 100644 --- a/static/app/actionCreators/organization.tsx +++ b/static/app/actionCreators/organization.tsx @@ -6,7 +6,7 @@ import * as Sentry from '@sentry/react'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {setActiveOrganization} from 'sentry/actionCreators/organizations'; -import type {ResponseMeta} from 'sentry/api'; +import type {ApiResult} from 'sentry/api'; import {Client} from 'sentry/api'; import OrganizationStore from 'sentry/stores/organizationStore'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; @@ -66,12 +66,7 @@ async function fetchOrg( async function fetchProjectsAndTeams( slug: string, usePreload?: boolean -): Promise< - [ - [Project[], string | undefined, XMLHttpRequest | ResponseMeta | undefined], - [Team[], string | undefined, XMLHttpRequest | ResponseMeta | undefined], - ] -> { +): Promise<[ApiResult, ApiResult]> { // Create a new client so the request is not cancelled const uncancelableApi = new Client(); diff --git a/static/app/bootstrap/index.tsx b/static/app/bootstrap/index.tsx index 7187c0d9efe5dd..ac2528628fc3a0 100644 --- a/static/app/bootstrap/index.tsx +++ b/static/app/bootstrap/index.tsx @@ -1,3 +1,4 @@ +import type {ResponseMeta} from 'sentry/api'; import type {Config} from 'sentry/types/system'; import {extractSlug} from 'sentry/utils/extractSlug'; @@ -40,27 +41,37 @@ async function bootWithHydration() { return data; } -function promiseRequest(url: string): Promise { - return new Promise(function (resolve, reject) { - const xhr = new XMLHttpRequest(); - xhr.open('GET', url); - xhr.setRequestHeader('sentry-trace', window.__initialData.initialTrace.sentry_trace); - xhr.setRequestHeader('baggage', window.__initialData.initialTrace.baggage); - xhr.withCredentials = true; - xhr.onload = function () { - try { - this.status >= 200 && this.status < 300 - ? resolve([JSON.parse(xhr.response), this.statusText, xhr]) - : reject([this.status, this.statusText]); - } catch (e) { - reject(); - } - }; - xhr.onerror = function () { - reject([this.status, this.statusText]); - }; - xhr.send(); - }); +async function promiseRequest(url: string) { + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json; charset=utf-8', + 'Content-Type': 'application/json', + 'sentry-trace': window.__initialData.initialTrace.sentry_trace, + baggage: window.__initialData.initialTrace.baggage, + }, + credentials: 'include', + priority: 'high', + }); + if (response.status >= 200 && response.status < 300) { + const text = await response.text(); + const json = JSON.parse(text); + const responseMeta: ResponseMeta = { + status: response.status, + statusText: response.statusText, + responseJSON: json, + responseText: text, + getResponseHeader: (header: string) => response.headers.get(header), + }; + return [json, response.statusText, responseMeta]; + } + // eslint-disable-next-line no-throw-literal + throw [response.status, response.statusText]; + } catch (error) { + // eslint-disable-next-line no-throw-literal + throw [error.status, error.statusText]; + } } function preloadOrganizationData(config: Config) { diff --git a/static/app/components/avatar/avatarList.tsx b/static/app/components/avatar/avatarList.tsx index e6234e62fd611f..f2ab4c36aea5a3 100644 --- a/static/app/components/avatar/avatarList.tsx +++ b/static/app/components/avatar/avatarList.tsx @@ -17,6 +17,10 @@ type Props = { avatarSize?: number; className?: string; maxVisibleAvatars?: number; + renderCollapsedAvatars?: ( + avatarSize: number, + numCollapsedAvatars: number + ) => React.ReactNode; renderTooltip?: UserAvatarProps['renderTooltip']; renderUsersFirst?: boolean; teams?: Team[]; @@ -25,8 +29,14 @@ type Props = { users?: Array; }; -const CollapsedAvatars = forwardRef(function CollapsedAvatars( - {size, children}: {children: React.ReactNode; size: number}, +export const CollapsedAvatars = forwardRef(function CollapsedAvatars( + { + size, + children, + }: { + children: React.ReactNode; + size: number; + }, ref: React.ForwardedRef ) { const hasStreamlinedUI = useHasStreamlinedUI(); @@ -55,6 +65,7 @@ function AvatarList({ teams = [], renderUsersFirst = false, renderTooltip, + renderCollapsedAvatars, }: Props) { const numTeams = teams.length; const numVisibleTeams = maxVisibleAvatars - numTeams > 0 ? numTeams : maxVisibleAvatars; @@ -82,14 +93,20 @@ function AvatarList({ return ( - {!!numCollapsedAvatars && ( - - - {numCollapsedAvatars < 99 && +} - {numCollapsedAvatars} - - - )} + {!!numCollapsedAvatars && + (renderCollapsedAvatars ? ( + renderCollapsedAvatars(avatarSize, numCollapsedAvatars) + ) : ( + + + {numCollapsedAvatars < 99 && +} + {numCollapsedAvatars} + + + ))} {renderUsersFirst ? visibleTeamAvatars.map(team => ( diff --git a/static/app/components/badge/alertBadge.tsx b/static/app/components/badge/alertBadge.tsx index 549511db604296..717085ff445283 100644 --- a/static/app/components/badge/alertBadge.tsx +++ b/static/app/components/badge/alertBadge.tsx @@ -1,7 +1,13 @@ import styled from '@emotion/styled'; import {DiamondStatus} from 'sentry/components/diamondStatus'; -import {IconCheckmark, IconExclamation, IconFire, IconIssues} from 'sentry/icons'; +import { + IconCheckmark, + IconExclamation, + IconFire, + IconIssues, + IconMute, +} from 'sentry/icons'; import type {SVGIconProps} from 'sentry/icons/svgIcon'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; @@ -13,6 +19,10 @@ type Props = { * @deprecated use withText */ hideText?: true; + /** + * Displays a "disabled" badge + */ + isDisabled?: boolean; /** * There is no status for issue, this is to facilitate this custom usage. */ @@ -31,12 +41,16 @@ type Props = { * This badge is a composition of DiamondStatus specifically used for incident * alerts. */ -function AlertBadge({status, withText, isIssue}: Props) { +function AlertBadge({status, withText, isIssue, isDisabled}: Props) { let statusText = t('Resolved'); let Icon: React.ComponentType = IconCheckmark; let color: ColorOrAlias = 'successText'; - if (isIssue) { + if (isDisabled) { + statusText = t('Disabled'); + Icon = IconMute; + color = 'disabled'; + } else if (isIssue) { statusText = t('Issue'); Icon = SizedIconIssue; color = 'subText'; diff --git a/static/app/components/charts/components/legend.tsx b/static/app/components/charts/components/legend.tsx index a252063383416a..21f52a36800b6c 100644 --- a/static/app/components/charts/components/legend.tsx +++ b/static/app/components/charts/components/legend.tsx @@ -15,7 +15,15 @@ export default function Legend( props: ChartProps['legend'] & {theme: Theme} ): LegendComponentOption { const {truncate, theme, ...rest} = props ?? {}; - const formatter = (value: string) => truncationFormatter(value, truncate ?? 0); + const formatter = (value: string) => + truncationFormatter( + value, + truncate ?? 0, + // Escaping the legend string will cause some special + // characters to render as their HTML equivalents. + // So disable it here. + false + ); return merge( { diff --git a/static/app/components/charts/utils.tsx b/static/app/components/charts/utils.tsx index b3b618fcf3e342..eed9e55caa7e23 100644 --- a/static/app/components/charts/utils.tsx +++ b/static/app/components/charts/utils.tsx @@ -41,16 +41,34 @@ export type DateTimeObject = Partial; export function truncationFormatter( value: string, - truncate: number | boolean | undefined + truncate: number | boolean | undefined, + escaped: boolean = true ): string { - if (!truncate) { - return escape(value); + // Whitespace characters such as newlines and tabs can + // mess up the formatting in legends where it's part of + // the formatting as it's handled by ECharts. + // + // In places like tooltips, it's already ignored and + // rendered as a single space. + // + // So remove whitespace characters such as newlines, + // tabs in favor of a space. + value = value.replace(/\s+/g, ' '); + + if (truncate) { + const truncationLength = + truncate && typeof truncate === 'number' ? truncate : DEFAULT_TRUNCATE_LENGTH; + value = + value.length > truncationLength + ? value.substring(0, truncationLength) + '…' + : value; } - const truncationLength = - truncate && typeof truncate === 'number' ? truncate : DEFAULT_TRUNCATE_LENGTH; - const truncated = - value.length > truncationLength ? value.substring(0, truncationLength) + '…' : value; - return escape(truncated); + + if (escaped) { + value = escape(value); + } + + return value; } /** diff --git a/static/app/components/contextPickerModal.tsx b/static/app/components/contextPickerModal.tsx index 92eb1a03a88e02..3da7cdd4aeab5f 100644 --- a/static/app/components/contextPickerModal.tsx +++ b/static/app/components/contextPickerModal.tsx @@ -1,24 +1,26 @@ -import {Component, Fragment} from 'react'; +import {Component, type Dispatch, Fragment, type SetStateAction, useState} from 'react'; import {components} from 'react-select'; import styled from '@emotion/styled'; import type {Query} from 'history'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent'; import type {StylesConfig} from 'sentry/components/forms/controls/selectControl'; import SelectControl from 'sentry/components/forms/controls/selectControl'; import IdBadge from 'sentry/components/idBadge'; import Link from 'sentry/components/links/link'; +import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {t, tct} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import OrganizationsStore from 'sentry/stores/organizationsStore'; import OrganizationStore from 'sentry/stores/organizationStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import {space} from 'sentry/styles/space'; import type {Integration} from 'sentry/types/integrations'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import Projects from 'sentry/utils/projects'; +import {useApiQuery} from 'sentry/utils/queryClient'; import replaceRouterParams from 'sentry/utils/replaceRouterParams'; import IntegrationIcon from 'sentry/views/settings/organizationIntegrations/integrationIcon'; @@ -61,7 +63,7 @@ type Props = SharedProps & { /** * Organization slug */ - organization: string; + organization: string | undefined; /** * List of available organizations @@ -128,7 +130,7 @@ class ContextPickerModal extends Component { navigateIfFinish = ( organizations: Array<{slug: string}>, projects: Array<{slug: string}>, - latestOrg: string = this.props.organization + latestOrg = this.props.organization ) => { const {needProject, onFinish, nextPath, integrationConfigs} = this.props; const {isSuperuser} = ConfigStore.get('user') || {}; @@ -411,103 +413,98 @@ type ContainerProps = SharedProps & { * List of slugs we want to be able to choose from */ projectSlugs?: string[]; -} & DeprecatedAsyncComponent['props']; +}; -type ContainerState = { - organizations: Organization[]; - integrationConfigs?: Integration[]; - selectedOrganization?: string; -} & DeprecatedAsyncComponent['state']; - -class ContextPickerModalContainer extends DeprecatedAsyncComponent< - ContainerProps, - ContainerState -> { - getDefaultState() { - const storeState = OrganizationStore.get(); - return { - ...super.getDefaultState(), - organizations: OrganizationsStore.getAll(), - selectedOrganization: storeState.organization?.slug, - }; - } +export default function ContextPickerModalContainer(props: ContainerProps) { + const {configUrl, projectSlugs, ...sharedProps} = props; - getEndpoints(): ReturnType { - const {configUrl} = this.props; - if (configUrl) { - return [['integrationConfigs', configUrl]]; - } - return []; - } + const {organizations} = useLegacyStore(OrganizationsStore); - componentWillUnmount() { - this.unlistener?.(); - } + const {organization} = useLegacyStore(OrganizationStore); + const [selectedOrgSlug, setSelectedOrgSlug] = useState(organization?.slug); - unlistener = OrganizationsStore.listen( - (organizations: Organization[]) => this.setState({organizations}), - undefined - ); - - handleSelectOrganization = (organizationSlug: string) => { - this.setState({selectedOrganization: organizationSlug}); - }; - - renderModal({ - projects, - initiallyLoaded, - integrationConfigs, - }: { - initiallyLoaded?: boolean; - integrationConfigs?: Integration[]; - projects?: Project[]; - }) { + if (configUrl) { return ( - ); } + if (selectedOrgSlug) { + return ( + + {({projects, initiallyLoaded}) => ( + + )} + + ); + } - render() { - const {projectSlugs, configUrl} = this.props; + return ( + + ); +} - if (configUrl && this.state.loading) { - return ; - } - if (this.state.integrationConfigs?.length) { - return this.renderModal({ - integrationConfigs: this.state.integrationConfigs, - initiallyLoaded: !this.state.loading, - }); - } - if (this.state.selectedOrganization) { - return ( - - {({projects, initiallyLoaded}) => - this.renderModal({projects: projects as Project[], initiallyLoaded}) - } - - ); - } +function ConfigUrlContainer( + props: SharedProps & { + configUrl: string; + selectedOrgSlug: string | undefined; + setSelectedOrgSlug: Dispatch>; + } +) { + const {configUrl, selectedOrgSlug, setSelectedOrgSlug, ...sharedProps} = props; + + const {organizations} = useLegacyStore(OrganizationsStore); - return this.renderModal({}); + const {data, isError, isPending, refetch} = useApiQuery([configUrl], { + staleTime: Infinity, + }); + + if (isPending) { + return ; + } + if (isError) { + return ; } + if (!data.length) { + sharedProps.onFinish(sharedProps.nextPath); + } + return ( + + ); } -export default ContextPickerModalContainer; - const StyledSelectControl = styled(SelectControl)` margin-top: ${space(1)}; `; diff --git a/static/app/components/deprecatedAssigneeSelector.spec.tsx b/static/app/components/deprecatedAssigneeSelector.spec.tsx deleted file mode 100644 index ad359ce728da1d..00000000000000 --- a/static/app/components/deprecatedAssigneeSelector.spec.tsx +++ /dev/null @@ -1,409 +0,0 @@ -import {GroupFixture} from 'sentry-fixture/group'; -import {ProjectFixture} from 'sentry-fixture/project'; -import {TeamFixture} from 'sentry-fixture/team'; -import {UserFixture} from 'sentry-fixture/user'; - -import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {openInviteMembersModal} from 'sentry/actionCreators/modal'; -import DeprecatedAssigneeSelector from 'sentry/components/deprecatedAssigneeSelector'; -import {putSessionUserFirst} from 'sentry/components/deprecatedAssigneeSelectorDropdown'; -import ConfigStore from 'sentry/stores/configStore'; -import GroupStore from 'sentry/stores/groupStore'; -import IndicatorStore from 'sentry/stores/indicatorStore'; -import MemberListStore from 'sentry/stores/memberListStore'; -import ProjectsStore from 'sentry/stores/projectsStore'; -import TeamStore from 'sentry/stores/teamStore'; -import type {Group} from 'sentry/types/group'; -import type {Team} from 'sentry/types/organization'; -import type {Project} from 'sentry/types/project'; -import type {User} from 'sentry/types/user'; - -jest.mock('sentry/actionCreators/modal', () => ({ - openInviteMembersModal: jest.fn(), -})); - -describe('DeprecatedAssigneeSelector', () => { - let assignMock: jest.Mock; - let assignGroup2Mock: jest.Mock; - let USER_1: User; - let USER_2: User; - let USER_3: User; - let USER_4: User; - let TEAM_1: Team; - let PROJECT_1: Project; - let GROUP_1: Group; - let GROUP_2: Group; - - beforeEach(() => { - USER_1 = UserFixture({ - id: '1', - name: 'Jane Bloggs', - email: 'janebloggs@example.com', - }); - USER_2 = UserFixture({ - id: '2', - name: 'John Smith', - email: 'johnsmith@example.com', - }); - USER_3 = UserFixture({ - id: '3', - name: 'J J', - email: 'jj@example.com', - }); - USER_4 = UserFixture({ - id: '4', - name: 'Jane Doe', - email: 'janedoe@example.com', - }); - - TEAM_1 = TeamFixture({ - id: '3', - name: 'COOL TEAM', - slug: 'cool-team', - }); - - PROJECT_1 = ProjectFixture({ - teams: [TEAM_1], - }); - - GROUP_1 = GroupFixture({ - id: '1337', - project: PROJECT_1, - }); - - GROUP_2 = GroupFixture({ - id: '1338', - project: PROJECT_1, - owners: [ - { - type: 'suspectCommit', - owner: `user:${USER_1.id}`, - date_added: '', - }, - ], - }); - TeamStore.reset(); - TeamStore.setTeams([TEAM_1]); - GroupStore.reset(); - GroupStore.loadInitialData([GROUP_1, GROUP_2]); - - jest.spyOn(MemberListStore, 'getAll').mockImplementation(() => []); - jest.spyOn(GroupStore, 'get').mockImplementation(() => GROUP_1); - - assignMock = MockApiClient.addMockResponse({ - method: 'PUT', - url: `/organizations/org-slug/issues/${GROUP_1.id}/`, - body: { - ...GROUP_1, - assignedTo: {...USER_1, type: 'user'}, - }, - }); - - assignGroup2Mock = MockApiClient.addMockResponse({ - method: 'PUT', - url: `/organizations/org-slug/issues/${GROUP_2.id}/`, - body: { - ...GROUP_2, - assignedTo: {...USER_1, type: 'user'}, - }, - }); - - MemberListStore.reset(); - ProjectsStore.loadInitialData([PROJECT_1]); - }); - - // Doesn't need to always be async, but it was easier to prevent flakes this way - const openMenu = async () => { - await userEvent.click(await screen.findByTestId('assignee-selector'), undefined); - }; - - afterEach(() => { - ProjectsStore.reset(); - MockApiClient.clearMockResponses(); - }); - - describe('render with props', () => { - it('renders members from the prop when present', async () => { - MemberListStore.loadInitialData([USER_1]); - render( - - ); - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - - // 3 total items - expect(screen.getAllByTestId('assignee-option')).toHaveLength(3); - // 1 team - expect(screen.getByText(`#${TEAM_1.slug}`)).toBeInTheDocument(); - // 2 Users - expect(screen.getByText(USER_2.name)).toBeInTheDocument(); - expect(screen.getByText(USER_3.name)).toBeInTheDocument(); - }); - }); - - describe('putSessionUserFirst()', () => { - it('should place the session user at the top of the member list if present', () => { - render(); - jest.spyOn(ConfigStore, 'get').mockImplementation(() => USER_2); - expect(putSessionUserFirst([USER_1, USER_2])).toEqual([USER_2, USER_1]); - jest.mocked(ConfigStore.get).mockRestore(); - }); - - it("should return the same member list if the session user isn't present", () => { - render(); - jest.spyOn(ConfigStore, 'get').mockImplementation(() => - UserFixture({ - id: '555', - name: 'Here Comes a New Challenger', - email: 'guile@mail.us.af.mil', - }) - ); - - expect(putSessionUserFirst([USER_1, USER_2])).toEqual([USER_1, USER_2]); - jest.mocked(ConfigStore.get).mockRestore(); - }); - }); - - it('should initially have loading state', async () => { - render(); - await openMenu(); - expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); - }); - - it('does not have loading state and shows member list after calling MemberListStore.loadInitialData', async () => { - render(); - act(() => MemberListStore.loadInitialData([USER_1, USER_2])); - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - - // 3 total items - expect(screen.getAllByTestId('assignee-option')).toHaveLength(3); - // 1 team - expect(screen.getByText(`#${TEAM_1.slug}`)).toBeInTheDocument(); - // 2 Users including self - expect(screen.getByText(`${USER_1.name} (You)`)).toBeInTheDocument(); - expect(screen.getByText(USER_2.name)).toBeInTheDocument(); - }); - - it('does NOT update member list after initial load', async () => { - render(); - act(() => MemberListStore.loadInitialData([USER_1, USER_2])); - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - - expect(screen.getByText(`${USER_1.name} (You)`)).toBeInTheDocument(); - expect(screen.getByText(USER_2.name)).toBeInTheDocument(); - - act(() => MemberListStore.loadInitialData([USER_1, USER_2, USER_3])); - - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - expect(screen.getByText(`${USER_1.name} (You)`)).toBeInTheDocument(); - expect(screen.getByText(USER_2.name)).toBeInTheDocument(); - expect(screen.queryByText(USER_3.name)).not.toBeInTheDocument(); - }); - - it('successfully assigns users', async () => { - render(); - act(() => MemberListStore.loadInitialData([USER_1, USER_2])); - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByText(`${USER_1.name} (You)`)); - - expect(assignMock).toHaveBeenLastCalledWith( - '/organizations/org-slug/issues/1337/', - expect.objectContaining({ - data: {assignedTo: 'user:1', assignedBy: 'assignee_selector'}, - }) - ); - - expect(await screen.findByTestId('letter_avatar-avatar')).toBeInTheDocument(); - // USER_1 initials - expect(screen.getByTestId('assignee-selector')).toHaveTextContent('JB'); - }); - - it('successfully assigns teams', async () => { - MockApiClient.clearMockResponses(); - assignMock = MockApiClient.addMockResponse({ - method: 'PUT', - url: `/organizations/org-slug/issues/${GROUP_1.id}/`, - body: { - ...GROUP_1, - assignedTo: {...TEAM_1, type: 'team'}, - }, - }); - render(); - act(() => MemberListStore.loadInitialData([USER_1, USER_2])); - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByText(`#${TEAM_1.slug}`)); - - await waitFor(() => - expect(assignMock).toHaveBeenCalledWith( - '/organizations/org-slug/issues/1337/', - expect.objectContaining({ - data: {assignedTo: 'team:3', assignedBy: 'assignee_selector'}, - }) - ) - ); - - expect(await screen.findByTestId('letter_avatar-avatar')).toBeInTheDocument(); - // TEAM_1 initials - expect(screen.getByTestId('assignee-selector')).toHaveTextContent('CT'); - }); - - it('successfully clears assignment', async () => { - render(); - act(() => MemberListStore.loadInitialData([USER_1, USER_2])); - await openMenu(); - - // Assign first item in list, which is TEAM_1 - await userEvent.click(screen.getByText(`#${TEAM_1.slug}`)); - - await waitFor(() => - expect(assignMock).toHaveBeenCalledWith( - '/organizations/org-slug/issues/1337/', - expect.objectContaining({ - data: {assignedTo: 'team:3', assignedBy: 'assignee_selector'}, - }) - ) - ); - - await openMenu(); - await userEvent.click(screen.getByRole('button', {name: 'Clear Assignee'})); - - // api was called with empty string, clearing assignment - await waitFor(() => - expect(assignMock).toHaveBeenLastCalledWith( - '/organizations/org-slug/issues/1337/', - expect.objectContaining({ - data: {assignedTo: '', assignedBy: 'assignee_selector'}, - }) - ) - ); - }); - - it('shows invite member button', async () => { - MemberListStore.loadInitialData([USER_1, USER_2]); - render(); - jest.spyOn(ConfigStore, 'get').mockImplementation(() => true); - - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - - await userEvent.click(await screen.findByRole('link', {name: 'Invite Member'})); - expect(openInviteMembersModal).toHaveBeenCalled(); - jest.mocked(ConfigStore.get).mockRestore(); - }); - - it('filters user by email and selects with keyboard', async () => { - render(); - act(() => MemberListStore.loadInitialData([USER_1, USER_2])); - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - - await userEvent.type(screen.getByRole('textbox'), 'JohnSmith@example.com'); - - // 1 total item - expect(screen.getByTestId('assignee-option')).toBeInTheDocument(); - expect(screen.getByText(`${USER_2.name}`)).toBeInTheDocument(); - - await userEvent.keyboard('{enter}'); - - await waitFor(() => - expect(assignGroup2Mock).toHaveBeenLastCalledWith( - '/organizations/org-slug/issues/1338/', - expect.objectContaining({ - data: {assignedTo: `user:${USER_2.id}`, assignedBy: 'assignee_selector'}, - }) - ) - ); - - expect(await screen.findByTestId('letter_avatar-avatar')).toBeInTheDocument(); - // USER_2 initials - expect(screen.getByTestId('assignee-selector')).toHaveTextContent('JB'); - }); - - it('shows the correct toast for assigning to a non-team member', async () => { - jest.spyOn(GroupStore, 'get').mockImplementation(() => GROUP_2); - const addMessageSpy = jest.spyOn(IndicatorStore, 'addMessage'); - - render(); - act(() => MemberListStore.loadInitialData([USER_1, USER_2, USER_3, USER_4])); - - assignMock = MockApiClient.addMockResponse({ - method: 'PUT', - url: `/organizations/org-slug/issues/${GROUP_2.id}/`, - statusCode: 400, - body: {detail: 'Cannot assign to non-team member'}, - }); - - expect(screen.getByTestId('suggested-avatar-stack')).toBeInTheDocument(); - - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - expect(screen.getByText(`#${TEAM_1.slug}`)).toBeInTheDocument(); - expect(await screen.findByText('Suggested Assignees')).toBeInTheDocument(); - - const options = screen.getAllByTestId('assignee-option'); - expect(options[5]).toHaveTextContent('JD'); - await userEvent.click(options[4]!); - - await waitFor(() => { - expect(addMessageSpy).toHaveBeenCalledWith( - 'Cannot assign to non-team member', - 'error', - {duration: 4000} - ); - }); - }); - - it('successfully shows suggested assignees', async () => { - jest.spyOn(GroupStore, 'get').mockImplementation(() => GROUP_2); - const onAssign = jest.fn(); - render(); - act(() => MemberListStore.loadInitialData([USER_1, USER_2, USER_3])); - - expect(screen.getByTestId('suggested-avatar-stack')).toBeInTheDocument(); - // Hover over avatar - await userEvent.hover(screen.getByTestId('letter_avatar-avatar')); - expect(await screen.findByText('Suggestion: Jane Bloggs')).toBeInTheDocument(); - expect(screen.getByText('commit data')).toBeInTheDocument(); - - await openMenu(); - expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument(); - expect(await screen.findByText('Suggested Assignees')).toBeInTheDocument(); - - const options = screen.getAllByTestId('assignee-option'); - // Suggested assignee initials - expect(options[0]).toHaveTextContent('JB'); - await userEvent.click(options[0]!); - - await waitFor(() => - expect(assignGroup2Mock).toHaveBeenCalledWith( - '/organizations/org-slug/issues/1338/', - expect.objectContaining({ - data: {assignedTo: `user:${USER_1.id}`, assignedBy: 'assignee_selector'}, - }) - ) - ); - - // Suggested assignees shouldn't show anymore because we assigned to the suggested actor - expect(screen.queryByTestId('suggested-avatar-stack')).not.toBeInTheDocument(); - expect(onAssign).toHaveBeenCalledWith( - 'member', - expect.objectContaining({id: USER_1.id}), - expect.objectContaining({id: USER_1.id}) - ); - }); - - it('renders unassigned', async () => { - jest.spyOn(GroupStore, 'get').mockImplementation(() => GROUP_1); - render(); - - await userEvent.hover(screen.getByTestId('unassigned')); - expect(await screen.findByText('Unassigned')).toBeInTheDocument(); - }); -}); diff --git a/static/app/components/deprecatedAssigneeSelector.tsx b/static/app/components/deprecatedAssigneeSelector.tsx deleted file mode 100644 index 029ec7ae6ce8e6..00000000000000 --- a/static/app/components/deprecatedAssigneeSelector.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import {Fragment} from 'react'; -import styled from '@emotion/styled'; - -import ActorAvatar from 'sentry/components/avatar/actorAvatar'; -import SuggestedAvatarStack from 'sentry/components/avatar/suggestedAvatarStack'; -import {Chevron} from 'sentry/components/chevron'; -import type { - DeprecatedAssigneeSelectorDropdownProps, - SuggestedAssignee, -} from 'sentry/components/deprecatedAssigneeSelectorDropdown'; -import {DeprecatedAssigneeSelectorDropdown} from 'sentry/components/deprecatedAssigneeSelectorDropdown'; -import ExternalLink from 'sentry/components/links/externalLink'; -import LoadingIndicator from 'sentry/components/loadingIndicator'; -import {Tooltip} from 'sentry/components/tooltip'; -import {IconUser} from 'sentry/icons'; -import {t, tct, tn} from 'sentry/locale'; -import GroupStore from 'sentry/stores/groupStore'; -import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import {space} from 'sentry/styles/space'; -import type {Actor} from 'sentry/types/core'; -import type {SuggestedOwnerReason} from 'sentry/types/group'; -import useOrganization from 'sentry/utils/useOrganization'; - -interface DeprecatedAssigneeSelectorProps - extends Omit< - DeprecatedAssigneeSelectorDropdownProps, - 'children' | 'organization' | 'assignedTo' - > { - noDropdown?: boolean; -} - -export function AssigneeAvatar({ - assignedTo, - suggestedActors = [], -}: { - assignedTo?: Actor | null; - suggestedActors?: SuggestedAssignee[]; -}) { - const suggestedReasons: Record = { - suspectCommit: tct('Based on [commit:commit data]', { - commit: ( - - ), - }), - ownershipRule: t('Matching Issue Owners Rule'), - projectOwnership: t('Matching Issue Owners Rule'), - codeowners: t('Matching Codeowners Rule'), - }; - const assignedToSuggestion = suggestedActors.find(actor => actor.id === assignedTo?.id); - - if (assignedTo) { - return ( - - {tct('Assigned to [name]', { - name: assignedTo.type === 'team' ? `#${assignedTo.name}` : assignedTo.name, - })} - {assignedToSuggestion && - suggestedReasons[assignedToSuggestion.suggestedReason] && ( - - {suggestedReasons[assignedToSuggestion.suggestedReason]} - - )} - - } - /> - ); - } - - if (suggestedActors.length > 0) { - const firstActor = suggestedActors[0]!; - return ( - -
- {tct('Suggestion: [name]', { - name: - firstActor.type === 'team' ? `#${firstActor.name}` : firstActor.name, - })} - {suggestedActors.length > 1 && - tn(' + %s other', ' + %s others', suggestedActors.length - 1)} -
- - {suggestedReasons[firstActor.suggestedReason]} - - - } - /> - ); - } - - return ( - -
{t('Unassigned')}
- - {tct( - 'You can auto-assign issues by adding [issueOwners:Issue Owner rules].', - { - issueOwners: ( - - ), - } - )} - - - } - > - -
- ); -} - -/** - * @deprecated use AssigneeSelectorDropdown instead (Coming in future PR) - */ -function DeprecatedAssigneeSelector({ - noDropdown, - ...props -}: DeprecatedAssigneeSelectorProps) { - const organization = useOrganization(); - const groups = useLegacyStore(GroupStore); - const group = groups.find(item => item.id === props.id); - - return ( - - - {({loading, isOpen, getActorProps, suggestedAssignees}) => { - const avatarElement = ( - - ); - - return ( - - {loading && ( - - )} - {!loading && !noDropdown && ( - - {avatarElement} - - - )} - {!loading && noDropdown && avatarElement} - - ); - }} - - - ); -} - -export default DeprecatedAssigneeSelector; - -const AssigneeWrapper = styled('div')` - display: flex; - justify-content: flex-end; - - /* manually align menu underneath dropdown caret */ -`; - -const StyledIconUser = styled(IconUser)` - /* We need this to center with Avatar */ - margin-right: 2px; -`; - -const DropdownButton = styled('div')` - display: flex; - align-items: center; - font-size: 20px; - gap: ${space(0.5)}; -`; - -const TooltipWrapper = styled('div')` - text-align: left; -`; - -const TooltipSubtext = styled('div')` - color: ${p => p.theme.subText}; -`; - -const TooltipSubExternalLink = styled(ExternalLink)` - color: ${p => p.theme.subText}; - text-decoration: underline; - - :hover { - color: ${p => p.theme.subText}; - } -`; diff --git a/static/app/components/deprecatedAssigneeSelectorDropdown.tsx b/static/app/components/deprecatedAssigneeSelectorDropdown.tsx deleted file mode 100644 index 0229af7d3533c5..00000000000000 --- a/static/app/components/deprecatedAssigneeSelectorDropdown.tsx +++ /dev/null @@ -1,675 +0,0 @@ -import {Component} from 'react'; -import styled from '@emotion/styled'; -import * as Sentry from '@sentry/react'; -import uniqBy from 'lodash/uniqBy'; - -import {assignToActor, assignToUser, clearAssignment} from 'sentry/actionCreators/group'; -import {openInviteMembersModal} from 'sentry/actionCreators/modal'; -import TeamAvatar from 'sentry/components/avatar/teamAvatar'; -import UserAvatar from 'sentry/components/avatar/userAvatar'; -import type {GetActorPropsFn} from 'sentry/components/deprecatedDropdownMenu'; -import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete'; -import type {ItemsBeforeFilter} from 'sentry/components/dropdownAutoComplete/types'; -import Link from 'sentry/components/links/link'; -import TextOverflow from 'sentry/components/textOverflow'; -import {IconAdd, IconClose} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import ConfigStore from 'sentry/stores/configStore'; -import GroupStore from 'sentry/stores/groupStore'; -import MemberListStore from 'sentry/stores/memberListStore'; -import ProjectsStore from 'sentry/stores/projectsStore'; -import {space} from 'sentry/styles/space'; -import type {Actor} from 'sentry/types/core'; -import type {Group, SuggestedOwner, SuggestedOwnerReason} from 'sentry/types/group'; -import type {Organization, Team} from 'sentry/types/organization'; -import type {User} from 'sentry/types/user'; -import {buildTeamId, buildUserId} from 'sentry/utils'; -import type {FeedbackIssue} from 'sentry/utils/feedback/types'; -import {valueIsEqual} from 'sentry/utils/object/valueIsEqual'; - -const suggestedReasonTable: Record = { - suspectCommit: t('Suspect Commit'), - ownershipRule: t('Ownership Rule'), - projectOwnership: t('Ownership Rule'), - // TODO: codeowners may no longer exist - codeowners: t('Codeowners'), -}; - -const onOpenNoop = (e?: React.MouseEvent) => { - e?.stopPropagation(); - - Sentry.withScope(scope => { - const span = Sentry.startInactiveSpan({ - name: 'assignee_selector_dropdown.open', - op: 'ui.render', - forceTransaction: true, - }); - - if (!span) { - return; - } - - if (typeof window.requestIdleCallback === 'function') { - scope.setTag('finish_strategy', 'idle_callback'); - window.requestIdleCallback(() => { - span.end(); - }); - } else { - scope.setTag('finish_strategy', 'timeout'); - setTimeout(() => { - span.end(); - }, 1_000); - } - }); -}; - -export type SuggestedAssignee = Actor & { - assignee: AssignableTeam | User; - suggestedReason: SuggestedOwnerReason; - suggestedReasonText?: React.ReactNode; -}; - -type AssignableTeam = { - display: string; - email: string; - id: string; - team: Team; -}; - -type RenderProps = { - getActorProps: GetActorPropsFn; - isOpen: boolean; - loading: boolean; - suggestedAssignees: SuggestedAssignee[]; -}; - -export type OnAssignCallback = ( - type: Actor['type'], - assignee: User | Actor, - suggestedAssignee?: SuggestedAssignee -) => void; - -export interface DeprecatedAssigneeSelectorDropdownProps { - children: (props: RenderProps) => React.ReactNode; - id: string; - organization: Organization; - alignMenu?: 'left' | 'right' | undefined; - assignedTo?: Actor | null; - disabled?: boolean; - group?: Group | FeedbackIssue; - memberList?: User[]; - onAssign?: OnAssignCallback; - onClear?: () => void; - owners?: Omit[]; -} - -type State = { - loading: boolean; - memberList?: User[]; - suggestedOwners?: SuggestedOwner[] | null; -}; - -/** - * @deprecated use AssigneeSelectorDropdown instead (Coming in future PR) - */ - -export class DeprecatedAssigneeSelectorDropdown extends Component< - DeprecatedAssigneeSelectorDropdownProps, - State -> { - state = this.getInitialState(); - - getInitialState() { - const group = GroupStore.get(this.props.id); - const memberList = MemberListStore.state.loading - ? undefined - : MemberListStore.getAll(); - - const loading = GroupStore.hasStatus(this.props.id, 'assignTo'); - const suggestedOwners = group?.owners; - - return { - assignedTo: group?.assignedTo, - memberList, - loading, - suggestedOwners, - }; - } - - UNSAFE_componentWillReceiveProps(nextProps: DeprecatedAssigneeSelectorDropdownProps) { - const loading = GroupStore.hasStatus(nextProps.id, 'assignTo'); - if (nextProps.id !== this.props.id || loading !== this.state.loading) { - const group = GroupStore.get(this.props.id); - this.setState({ - loading, - suggestedOwners: group?.owners, - }); - } - } - - shouldComponentUpdate( - nextProps: DeprecatedAssigneeSelectorDropdownProps, - nextState: State - ) { - if (nextState.loading !== this.state.loading) { - return true; - } - - // If the memberList in props has changed, re-render as - // props have updated, and we won't use internal state anyways. - if ( - nextProps.memberList && - !valueIsEqual(this.props.memberList, nextProps.memberList) - ) { - return true; - } - - if (!valueIsEqual(this.props.owners, nextProps.owners)) { - return true; - } - - const currentMembers = this.memberList(); - // XXX(billyvg): this means that once `memberList` is not-null, this component will never update due to `memberList` changes - // Note: this allows us to show a "loading" state for memberList, but only before `MemberListStore.loadInitialData` - // is called - if (currentMembers === undefined && nextState.memberList !== currentMembers) { - return true; - } - return !valueIsEqual(this.props.assignedTo, nextProps.assignedTo, true); - } - - componentWillUnmount() { - this.unlisteners.forEach(unlistener => unlistener?.()); - } - - unlisteners = [ - GroupStore.listen((itemIds: any) => this.onGroupChange(itemIds), undefined), - MemberListStore.listen(({members}: typeof MemberListStore.state) => { - this.handleMemberListUpdate(members); - }, undefined), - ]; - - handleMemberListUpdate = (members: User[]) => { - if (members === this.state.memberList) { - return; - } - - this.setState({memberList: members}); - }; - - memberList(): User[] | undefined { - return this.props.memberList ?? this.state.memberList; - } - - onGroupChange(itemIds: Set) { - if (!itemIds.has(this.props.id)) { - return; - } - const group = GroupStore.get(this.props.id); - this.setState({ - suggestedOwners: group?.owners, - loading: GroupStore.hasStatus(this.props.id, 'assignTo'), - }); - } - - assignableTeams(): AssignableTeam[] { - const group = GroupStore.get(this.props.id) ?? this.props.group; - if (!group) { - return []; - } - - const teams = ProjectsStore.getBySlug(group.project?.slug)?.teams ?? []; - return teams - .sort((a, b) => a.slug.localeCompare(b.slug)) - .map(team => ({ - id: buildTeamId(team.id), - display: `#${team.slug}`, - email: team.id, - team, - })); - } - - assignToUser(user: User | Actor) { - const {organization} = this.props; - assignToUser({ - id: this.props.id, - orgSlug: organization.slug, - user, - assignedBy: 'assignee_selector', - }); - this.setState({loading: true}); - } - - // Renamed to handleTeamAssign - assignToTeam(team: Team) { - const {organization} = this.props; - - assignToActor({ - actor: {id: team.id, type: 'team'}, - id: this.props.id, - orgSlug: organization.slug, - assignedBy: 'assignee_selector', - }); - this.setState({loading: true}); - } - - handleAssign: React.ComponentProps['onSelect'] = ( - {value: {type, assignee}}, - _state, - e - ) => { - if (type === 'member') { - this.assignToUser(assignee); - } - - if (type === 'team') { - this.assignToTeam(assignee); - } - - e?.stopPropagation(); - - const {onAssign} = this.props; - if (onAssign) { - const suggestionType = type === 'member' ? 'user' : type; - const suggestion = this.getSuggestedAssignees().find( - actor => actor.type === suggestionType && actor.id === assignee.id - ); - onAssign(type, assignee, suggestion); - } - }; - - clearAssignTo = (e: React.MouseEvent) => { - const {organization} = this.props; - - // clears assignment - clearAssignment(this.props.id, organization.slug, 'assignee_selector'); - this.setState({loading: true}); - const {onClear} = this.props; - - if (onClear) { - onClear(); - } - e.stopPropagation(); - }; - - renderMemberNode( - member: User, - suggestedReason?: React.ReactNode - ): ItemsBeforeFilter[0] { - const sessionUser = ConfigStore.get('user'); - const handleSelect = () => this.assignToUser(member); - - return { - value: {type: 'member', assignee: member}, - searchKey: `${member.email} ${member.name}`, - label: ( - - - - -
- - {sessionUser.id === member.id - ? `${member.name || member.email} ${t('(You)')}` - : member.name || member.email} - - {suggestedReason && ( - {suggestedReason} - )} -
-
- ), - }; - } - - renderNewMemberNodes(): ItemsBeforeFilter { - const members = putSessionUserFirst(this.memberList()); - return members.map(member => this.renderMemberNode(member)); - } - - renderTeamNode( - assignableTeam: AssignableTeam, - suggestedReason?: React.ReactNode - ): ItemsBeforeFilter[0] { - const {id, display, team} = assignableTeam; - - const handleSelect = () => this.assignToTeam(team); - - return { - value: {type: 'team', assignee: team}, - searchKey: team.slug, - label: ( - - - - -
- {display} - {suggestedReason && ( - {suggestedReason} - )} -
-
- ), - }; - } - - renderSuggestedAssigneeNodes(): React.ComponentProps< - typeof DropdownAutoComplete - >['items'] { - const {assignedTo} = this.props; - // filter out suggested assignees if a suggestion is already selected - const suggestedAssignees = this.getSuggestedAssignees(); - const renderedAssignees: ( - | ReturnType - | ReturnType - )[] = []; - - for (let i = 0; i < suggestedAssignees.length; i++) { - const assignee = suggestedAssignees[i]!; - if (assignee.type !== 'user' && assignee.type !== 'team') { - continue; - } - if (!(assignee.type !== assignedTo?.type && assignee.id !== assignedTo?.id)) { - continue; - } - - renderedAssignees.push( - assignee.type === 'user' - ? this.renderMemberNode(assignee.assignee as User, assignee.suggestedReasonText) - : this.renderTeamNode( - assignee.assignee as AssignableTeam, - assignee.suggestedReasonText - ) - ); - } - - return renderedAssignees; - } - - renderDropdownGroupLabel(label: string) { - return {label}; - } - - renderNewDropdownItems(): ItemsBeforeFilter { - const sessionUser = ConfigStore.get('user'); - const teams = this.assignableTeams().map(team => this.renderTeamNode(team)); - const members = this.renderNewMemberNodes(); - const suggestedAssignees = this.renderSuggestedAssigneeNodes() ?? []; - - const filteredSessionUser: ItemsBeforeFilter = members.filter( - member => member.value.assignee.id === sessionUser.id - ); - - const assigneeIds = new Set( - suggestedAssignees.map( - assignee => `${assignee.value.type}:${assignee.value.assignee.id}` - ) - ); - // filter out duplicates of Team/Member if also a Suggested Assignee - const filteredTeams: ItemsBeforeFilter = teams.filter(team => { - return !assigneeIds.has(`${team.value.type}:${team.value.assignee.id}`); - }); - const filteredMembers: ItemsBeforeFilter = members.filter(member => { - return ( - !assigneeIds.has(`${member.value.type}:${member.value.assignee.id}`) && - member.value.assignee.id !== sessionUser.id - ); - }); - - // New version combines teams and users into one section - const dropdownItems: ItemsBeforeFilter = [ - { - label: this.renderDropdownGroupLabel(t('Everyone Else')), - hideGroupLabel: !suggestedAssignees.length, - id: 'everyone-else', - items: filteredSessionUser.concat(filteredTeams, filteredMembers), - }, - ]; - - if (suggestedAssignees.length) { - // Add suggested assingees - dropdownItems.unshift({ - label: this.renderDropdownGroupLabel(t('Suggested Assignees')), - id: 'suggested-list', - items: suggestedAssignees, - }); - } - - return dropdownItems; - } - - renderInviteMemberLink() { - return ( - { - event.preventDefault(); - openInviteMembersModal({source: 'assignee_selector'}); - }} - > - - - - - - - - ); - } - - getSuggestedAssignees(): SuggestedAssignee[] { - const assignableTeams = this.assignableTeams(); - const memberList = this.memberList() ?? []; - - const {owners} = this.props; - if (owners !== undefined) { - // Add team or user from store - return owners - .map(owner => { - if (owner.type === 'user') { - const member = memberList.find(user => user.id === owner.id); - if (member) { - return { - ...owner, - assignee: member, - }; - } - } - if (owner.type === 'team') { - const matchingTeam = assignableTeams.find( - assignableTeam => assignableTeam.team.id === owner.id - ); - if (matchingTeam) { - return { - ...owner, - assignee: matchingTeam, - }; - } - } - - return null; - }) - .filter((owner): owner is SuggestedAssignee => !!owner); - } - - const {suggestedOwners} = this.state; - if (!suggestedOwners) { - return []; - } - - const uniqueSuggestions = uniqBy(suggestedOwners, owner => owner.owner); - return uniqueSuggestions - .map(owner => { - // converts a backend suggested owner to a suggested assignee - const [ownerType, id] = owner.owner.split(':'); - const suggestedReasonText = suggestedReasonTable[owner.type]; - if (ownerType === 'user') { - const member = memberList.find(user => user.id === id); - if (member) { - return { - id: id!, - type: 'user', - name: member.name, - suggestedReason: owner.type, - suggestedReasonText, - assignee: member, - }; - } - } else if (ownerType === 'team') { - const matchingTeam = assignableTeams.find( - assignableTeam => assignableTeam.id === owner.owner - ); - if (matchingTeam) { - return { - id: id!, - type: 'team', - name: matchingTeam.team.name, - suggestedReason: owner.type, - suggestedReasonText, - assignee: matchingTeam, - }; - } - } - - return null; - }) - .filter((owner): owner is SuggestedAssignee => !!owner); - } - - render() { - const {alignMenu, disabled, children, assignedTo} = this.props; - const {loading} = this.state; - const memberList = this.memberList(); - - return ( - null - } - onSelect={this.handleAssign} - alignMenu={alignMenu ?? 'right'} - itemSize="small" - searchPlaceholder={t('Filter teams and people')} - menuFooter={ - assignedTo ? ( -
- - - - - - - {this.renderInviteMemberLink()} -
- ) : ( - this.renderInviteMemberLink() - ) - } - disableLabelPadding - emptyHidesInput - > - {({getActorProps, isOpen}) => - children({ - loading, - isOpen, - getActorProps, - suggestedAssignees: this.getSuggestedAssignees(), - }) - } -
- ); - } -} - -export function putSessionUserFirst(members: User[] | undefined): User[] { - // If session user is in the filtered list of members, put them at the top - if (!members) { - return []; - } - - const sessionUser = ConfigStore.get('user'); - const sessionUserIndex = members.findIndex(member => member.id === sessionUser?.id); - - if (sessionUserIndex === -1) { - return members; - } - - const arrangedMembers = [members[sessionUserIndex]!].concat( - members.slice(0, sessionUserIndex), - members.slice(sessionUserIndex + 1) - ); - - return arrangedMembers; -} - -const IconContainer = styled('div')` - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - flex-shrink: 0; -`; - -const MenuItemWrapper = styled('div')` - display: flex; - align-items: center; - font-size: 13px; - padding: ${space(0.5)} ${space(0.5)}; -`; - -const MenuItemFooterWrapper = styled('div')` - display: flex; - align-items: center; - padding: ${space(0.25)} ${space(1)}; - border-top: 1px solid ${p => p.theme.innerBorder}; - background-color: ${p => p.theme.tag.highlight.background}; - color: ${p => p.theme.activeText}; - :hover { - color: ${p => p.theme.activeHover}; - svg { - fill: ${p => p.theme.activeHover}; - } - } -`; - -const InviteMemberLink = styled(Link)` - color: ${p => (p.disabled ? p.theme.disabled : p.theme.textColor)}; -`; - -const Label = styled(TextOverflow)` - margin-left: 6px; -`; - -const AssigneeLabel = styled('div')` - ${p => p.theme.overflowEllipsis} - margin-left: ${space(1)}; - max-width: 300px; -`; - -const SuggestedAssigneeReason = styled(AssigneeLabel)` - color: ${p => p.theme.subText}; - font-size: ${p => p.theme.fontSizeSmall}; -`; - -const GroupHeader = styled('div')` - font-size: 75%; - line-height: 1.5; - font-weight: ${p => p.theme.fontWeightBold}; - text-transform: uppercase; - margin: ${space(1)} 0; - color: ${p => p.theme.subText}; - text-align: left; -`; diff --git a/static/app/components/events/autofix/autofixMessageBox.tsx b/static/app/components/events/autofix/autofixMessageBox.tsx index 885d9837ce3193..5205f15c210084 100644 --- a/static/app/components/events/autofix/autofixMessageBox.tsx +++ b/static/app/components/events/autofix/autofixMessageBox.tsx @@ -481,7 +481,7 @@ function AutofixMessageBox({ } if (text.trim() !== '' || allowEmptyMessage) { - if (onSend != null) { + if (onSend !== null) { onSend(text); } else { send({ diff --git a/static/app/components/events/interfaces/spans/spanProfileDetails.tsx b/static/app/components/events/interfaces/spans/spanProfileDetails.tsx index 9f247c3fdcf9ac..249dc487dc38be 100644 --- a/static/app/components/events/interfaces/spans/spanProfileDetails.tsx +++ b/static/app/components/events/interfaces/spans/spanProfileDetails.tsx @@ -11,14 +11,18 @@ import {IconChevron, IconProfiling} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {EntryType, type EventTransaction, type Frame} from 'sentry/types/event'; -import type {PlatformKey} from 'sentry/types/project'; +import type {Organization} from 'sentry/types/organization'; +import type {PlatformKey, Project} from 'sentry/types/project'; import {StackView} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils'; import {formatPercentage} from 'sentry/utils/number/formatPercentage'; import {CallTreeNode} from 'sentry/utils/profiling/callTreeNode'; import {Frame as ProfilingFrame} from 'sentry/utils/profiling/frame'; import type {Profile} from 'sentry/utils/profiling/profile/profile'; -import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes'; +import { + generateContinuousProfileFlamechartRouteWithQuery, + generateProfileFlamechartRouteWithQuery, +} from 'sentry/utils/profiling/routes'; import {formatTo} from 'sentry/utils/profiling/units/units'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; @@ -37,7 +41,12 @@ interface SpanProfileDetailsProps { onNoProfileFound?: () => void; } -export function useSpanProfileDetails(event: any, span: any) { +export function useSpanProfileDetails( + organization: Organization, + project: Project | undefined, + event: Readonly, + span: Readonly +) { const profileGroup = useProfileGroup(); const processedEvent = useMemo(() => { @@ -131,9 +140,43 @@ export function useSpanProfileDetails(event: any, span: any) { }; }, [index, maxNodes, event, nodes]); + const profileTarget = useMemo(() => { + if (defined(project)) { + const profileContext = event.contexts.profile ?? {}; + + if (defined(profileContext.profile_id)) { + return generateProfileFlamechartRouteWithQuery({ + orgSlug: organization.slug, + projectSlug: project.slug, + profileId: profileContext.profile_id, + query: { + spanId: span.span_id, + }, + }); + } + + if (defined(profileContext.profiler_id)) { + return generateContinuousProfileFlamechartRouteWithQuery({ + orgSlug: organization.slug, + projectSlug: project.slug, + profilerId: profileContext.profiler_id, + start: new Date(event.startTimestamp * 1000).toISOString(), + end: new Date(event.endTimestamp * 1000).toISOString(), + query: { + eventId: event.id, + spanId: span.span_id, + }, + }); + } + } + + return undefined; + }, [organization, project, event, span]); + return { processedEvent, profileGroup, + profileTarget, profile, nodes, index, @@ -156,8 +199,7 @@ export function SpanProfileDetails({ const project = projects.find(p => p.id === event.projectID); const { processedEvent, - profileGroup, - profile, + profileTarget, nodes, index, setIndex, @@ -166,25 +208,9 @@ export function SpanProfileDetails({ hasPrevious, totalWeight, frames, - } = useSpanProfileDetails(event, span); - - const spanTarget = - project && - profileGroup && - profileGroup.metadata.profileID && - profile && - generateProfileFlamechartRouteWithQuery({ - orgSlug: organization.slug, - projectSlug: project.slug, - profileId: profileGroup.metadata.profileID, - query: { - tid: String(profile.threadId), - spanId: span.span_id, - sorting: 'call order', - }, - }); - - if (!defined(profile) || !defined(spanTarget)) { + } = useSpanProfileDetails(organization, project, event, span); + + if (!defined(profileTarget)) { return null; } @@ -245,7 +271,7 @@ export function SpanProfileDetails({ - } to={spanTarget} size="xs"> + } to={profileTarget} size="xs"> {t('Profile')} diff --git a/static/app/components/feedback/feedbackItem/feedbackAssignedTo.spec.tsx b/static/app/components/feedback/feedbackItem/feedbackAssignedTo.spec.tsx new file mode 100644 index 00000000000000..fdc3e4c62db3bc --- /dev/null +++ b/static/app/components/feedback/feedbackItem/feedbackAssignedTo.spec.tsx @@ -0,0 +1,93 @@ +import {EventFixture} from 'sentry-fixture/event'; +import {FeedbackIssueFixture} from 'sentry-fixture/feedbackIssue'; +import {MemberFixture} from 'sentry-fixture/member'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; +import {UserFixture} from 'sentry-fixture/user'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import MemberListStore from 'sentry/stores/memberListStore'; +import type {Group} from 'sentry/types/group'; + +import FeedbackAssignedTo from './feedbackAssignedTo'; + +describe('FeedbackAssignedTo', () => { + const user = UserFixture(); + const organization = OrganizationFixture(); + const feedbackIssue = FeedbackIssueFixture({}) as unknown as Group; + const feedbackEvent = EventFixture(); + const project = ProjectFixture(); + + beforeEach(() => { + MemberListStore.reset(); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/users/`, + body: [MemberFixture({user})], + }); + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/events/${feedbackEvent.id}/owners/`, + body: { + owners: [], + rules: [], + }, + }); + }); + + it('should assign to user', async () => { + const assignMock = MockApiClient.addMockResponse({ + method: 'PUT', + url: `/organizations/${organization.slug}/issues/${feedbackIssue.id}/`, + body: {...feedbackIssue, assignedTo: {id: user.id, type: 'user', name: user.name}}, + }); + + render( + + ); + + await userEvent.click(await screen.findByLabelText('Modify issue assignee')); + await userEvent.click(screen.getByText(`${user.name} (You)`)); + + await waitFor(() => + expect(assignMock).toHaveBeenLastCalledWith( + `/organizations/${organization.slug}/issues/${feedbackIssue.id}/`, + expect.objectContaining({ + data: {assignedTo: `user:${user.id}`, assignedBy: 'assignee_selector'}, + }) + ) + ); + expect(assignMock).toHaveBeenCalledTimes(1); + }); + + it('should clear assignee', async () => { + const assignMock = MockApiClient.addMockResponse({ + method: 'PUT', + url: `/organizations/${organization.slug}/issues/${feedbackIssue.id}/`, + body: {...feedbackIssue, assignedTo: null}, + }); + + render( + + ); + + await userEvent.click(await screen.findByLabelText('Modify issue assignee')); + await userEvent.click(await screen.findByRole('button', {name: 'Clear'})); + + await waitFor(() => + expect(assignMock).toHaveBeenLastCalledWith( + `/organizations/${organization.slug}/issues/${feedbackIssue.id}/`, + expect.objectContaining({ + data: {assignedBy: 'assignee_selector', assignedTo: ''}, + }) + ) + ); + expect(assignMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/static/app/components/feedback/feedbackItem/feedbackAssignedTo.tsx b/static/app/components/feedback/feedbackItem/feedbackAssignedTo.tsx index e9b6c7f546557f..bac9c24fd60936 100644 --- a/static/app/components/feedback/feedbackItem/feedbackAssignedTo.tsx +++ b/static/app/components/feedback/feedbackItem/feedbackAssignedTo.tsx @@ -1,14 +1,13 @@ import {useEffect} from 'react'; import {fetchOrgMembers} from 'sentry/actionCreators/members'; -import useMutateFeedback from 'sentry/components/feedback/useMutateFeedback'; +import useFeedbackCache from 'sentry/components/feedback/useFeedbackCache'; import type {EventOwners} from 'sentry/components/group/assignedTo'; import {getOwnerList} from 'sentry/components/group/assignedTo'; import { AssigneeSelector, useHandleAssigneeChange, } from 'sentry/components/group/assigneeSelector'; -import type {Actor} from 'sentry/types/core'; import type {Group} from 'sentry/types/group'; import type {FeedbackEvent} from 'sentry/utils/feedback/types'; import {useApiQuery} from 'sentry/utils/queryClient'; @@ -38,15 +37,13 @@ export default function FeedbackAssignedTo({feedbackIssue, feedbackEvent}: Props enabled: Boolean(feedbackEvent), } ); + const {updateCached} = useFeedbackCache(); const {handleAssigneeChange, assigneeLoading} = useHandleAssigneeChange({ organization, group: feedbackIssue, - }); - - const {assign} = useMutateFeedback({ - feedbackIds: [feedbackIssue.id], - organization, - projectIds: [feedbackIssue.project.id], + onSuccess: assignedTo => { + updateCached([feedbackIssue.id], {assignedTo}); + }, }); const owners = getOwnerList([], eventOwners, feedbackIssue.assignedTo); @@ -57,7 +54,6 @@ export default function FeedbackAssignedTo({feedbackIssue, feedbackEvent}: Props owners={owners} assigneeLoading={assigneeLoading} handleAssigneeChange={e => { - assign(e?.assignee as Actor); handleAssigneeChange(e); }} /> diff --git a/static/app/components/feedback/useMutateFeedback.tsx b/static/app/components/feedback/useMutateFeedback.tsx index 4ba1c5b91c682a..d8d119d3991b3f 100644 --- a/static/app/components/feedback/useMutateFeedback.tsx +++ b/static/app/components/feedback/useMutateFeedback.tsx @@ -79,19 +79,8 @@ export default function useMutateFeedback({ [mutation, feedbackIds] ); - const assign = useCallback( - ( - assignedTo: Actor | undefined, - options?: MutateOptions - ) => { - mutation.mutate([feedbackIds, {assignedTo}], options); - }, - [mutation, feedbackIds] - ); - return { markAsRead, resolve, - assign, }; } diff --git a/static/app/components/forms/formField/index.tsx b/static/app/components/forms/formField/index.tsx index a654ca12bfb743..5acc670288c950 100644 --- a/static/app/components/forms/formField/index.tsx +++ b/static/app/components/forms/formField/index.tsx @@ -370,6 +370,10 @@ function FormField(props: FormFieldProps) { error, initialData: model.initialData, 'aria-describedby': `${id}_help`, + placeholder: + typeof fieldProps.placeholder === 'function' + ? fieldProps.placeholder({...props, model}) + : fieldProps.placeholder, })} ); diff --git a/static/app/components/group/assignedTo.tsx b/static/app/components/group/assignedTo.tsx index a7b576a583342c..b41ca52886c811 100644 --- a/static/app/components/group/assignedTo.tsx +++ b/static/app/components/group/assignedTo.tsx @@ -9,11 +9,10 @@ import GuideAnchor from 'sentry/components/assistant/guideAnchor'; import ActorAvatar from 'sentry/components/avatar/actorAvatar'; import {Button} from 'sentry/components/button'; import {Chevron} from 'sentry/components/chevron'; -import type { - OnAssignCallback, - SuggestedAssignee, -} from 'sentry/components/deprecatedAssigneeSelectorDropdown'; -import {useHandleAssigneeChange} from 'sentry/components/group/assigneeSelector'; +import { + type OnAssignCallback, + useHandleAssigneeChange, +} from 'sentry/components/group/assigneeSelector'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import * as SidebarSection from 'sentry/components/sidebarSection'; import {IconSettings, IconUser} from 'sentry/icons'; @@ -23,9 +22,11 @@ import TeamStore from 'sentry/stores/teamStore'; import {space} from 'sentry/styles/space'; import type {Actor} from 'sentry/types/core'; import type {Event} from 'sentry/types/event'; -import type {Group} from 'sentry/types/group'; +import type {Group, SuggestedOwnerReason} from 'sentry/types/group'; import type {Commit, Committer} from 'sentry/types/integrations'; +import type {Team} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; +import type {User} from 'sentry/types/user'; import type {FeedbackIssue} from 'sentry/utils/feedback/types'; import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import useApi from 'sentry/utils/useApi'; @@ -99,6 +100,19 @@ function getSuggestedReason(owner: IssueOwner) { return ''; } +type SuggestedAssignee = Actor & { + assignee: AssignableTeam | User; + suggestedReason: SuggestedOwnerReason; + suggestedReasonText?: React.ReactNode; +}; + +type AssignableTeam = { + display: string; + email: string; + id: string; + team: Team; +}; + /** * Combine the committer and ownership data into a single array, merging * users who are both owners based on having commits, and owners matching diff --git a/static/app/components/group/assigneeSelector.tsx b/static/app/components/group/assigneeSelector.tsx index 90b6f6a65da19c..f03a6700142d42 100644 --- a/static/app/components/group/assigneeSelector.tsx +++ b/static/app/components/group/assigneeSelector.tsx @@ -8,13 +8,12 @@ import AssigneeSelectorDropdown, { type SuggestedAssignee, } from 'sentry/components/assigneeSelectorDropdown'; import {Button} from 'sentry/components/button'; -import type {OnAssignCallback} from 'sentry/components/deprecatedAssigneeSelectorDropdown'; import {t} from 'sentry/locale'; +import type {Actor} from 'sentry/types/core'; import type {Group} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {User} from 'sentry/types/user'; import {useMutation} from 'sentry/utils/queryClient'; -import type RequestError from 'sentry/utils/requestError/requestError'; interface AssigneeSelectorProps { assigneeLoading: boolean; @@ -25,40 +24,41 @@ interface AssigneeSelectorProps { owners?: Omit[]; } +export type OnAssignCallback = ( + type: Actor['type'], + assignee: User | Actor, + suggestedAssignee?: SuggestedAssignee +) => void; + export function useHandleAssigneeChange({ organization, group, onAssign, + onSuccess, }: { group: Group; organization: Organization; onAssign?: OnAssignCallback; + onSuccess?: (assignedTo: Group['assignedTo']) => void; }) { - const {mutate: handleAssigneeChange, isPending: assigneeLoading} = useMutation< - AssignableEntity | null, - RequestError, - AssignableEntity | null - >({ - mutationFn: async ( - newAssignee: AssignableEntity | null - ): Promise => { + const {mutate: handleAssigneeChange, isPending: assigneeLoading} = useMutation({ + mutationFn: (newAssignee: AssignableEntity | null): Promise => { if (newAssignee) { - await assignToActor({ + return assignToActor({ id: group.id, orgSlug: organization.slug, actor: {id: newAssignee.id, type: newAssignee.type}, assignedBy: 'assignee_selector', }); - return Promise.resolve(newAssignee); } - await clearAssignment(group.id, organization.slug, 'assignee_selector'); - return Promise.resolve(null); + return clearAssignment(group.id, organization.slug, 'assignee_selector'); }, - onSuccess: (newAssignee: AssignableEntity | null) => { + onSuccess: (updatedGroup, newAssignee) => { if (onAssign && newAssignee) { onAssign(newAssignee.type, newAssignee.assignee, newAssignee.suggestedAssignee); } + onSuccess?.(updatedGroup.assignedTo); }, onError: () => { addErrorMessage('Failed to update assignee'); diff --git a/static/app/components/modals/widgetViewerModal.tsx b/static/app/components/modals/widgetViewerModal.tsx index fd5503b30fa132..311e8a27c1e455 100644 --- a/static/app/components/modals/widgetViewerModal.tsx +++ b/static/app/components/modals/widgetViewerModal.tsx @@ -29,7 +29,7 @@ import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {PageFilters, SelectValue} from 'sentry/types/core'; import type {Series} from 'sentry/types/echarts'; -import type {Organization} from 'sentry/types/organization'; +import type {Confidence, Organization} from 'sentry/types/organization'; import type {User} from 'sentry/types/user'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -110,6 +110,7 @@ export interface WidgetViewerModalOptions { organization: Organization; widget: Widget; widgetLegendState: WidgetLegendSelectionState; + confidence?: Confidence; dashboardCreator?: User; dashboardFilters?: DashboardFilters; dashboardPermissions?: DashboardPermissions; @@ -195,6 +196,7 @@ function WidgetViewerModal(props: Props) { widgetLegendState, dashboardPermissions, dashboardCreator, + confidence, } = props; const location = useLocation(); const {projects} = useProjects(); @@ -852,6 +854,8 @@ function WidgetViewerModal(props: Props) { expandNumbers noPadding widgetLegendState={widgetLegendState} + showConfidenceWarning={widget.widgetType === WidgetType.SPANS} + confidence={confidence} /> ) : ( )} diff --git a/static/app/components/onboarding/gettingStartedDoc/storeCrashReportsConfig.tsx b/static/app/components/onboarding/gettingStartedDoc/storeCrashReportsConfig.tsx new file mode 100644 index 00000000000000..f044081de4b80d --- /dev/null +++ b/static/app/components/onboarding/gettingStartedDoc/storeCrashReportsConfig.tsx @@ -0,0 +1,74 @@ +import styled from '@emotion/styled'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {hasEveryAccess} from 'sentry/components/acl/access'; +import Form from 'sentry/components/forms/form'; +import JsonForm from 'sentry/components/forms/jsonForm'; +import Panel from 'sentry/components/panels/panel'; +import Placeholder from 'sentry/components/placeholder'; +import projectSecurityAndPrivacyGroups from 'sentry/data/forms/projectSecurityAndPrivacyGroups'; +import ProjectsStore from 'sentry/stores/projectsStore'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import {useDetailedProject} from 'sentry/utils/useDetailedProject'; + +interface StoreCrashReportsConfigProps { + organization: Organization; + projectSlug: Project['slug']; +} + +export function StoreCrashReportsConfig({ + projectSlug, + organization, +}: StoreCrashReportsConfigProps) { + const {data: project, isPending: isPendingProject} = useDetailedProject({ + orgSlug: organization.slug, + projectSlug, + }); + + if (isPendingProject) { + // 72px is the height of the form + return ; + } + + const storeCrashReportsField = projectSecurityAndPrivacyGroups + .flatMap(group => group.fields) + .find(field => field.name === 'storeCrashReports'); + + if (!project || !storeCrashReportsField) { + return null; + } + + return ( +
{ + // This will update our project global state + ProjectsStore.onUpdateSuccess(data); + }} + onSubmitError={() => addErrorMessage('Unable to save change')} + > + + + ); +} + +const StyledJsonForm = styled(JsonForm)` + ${Panel} { + margin-bottom: 0; + } +`; diff --git a/static/app/components/onboarding/productSelection.tsx b/static/app/components/onboarding/productSelection.tsx index e671a9615602cd..f42e61722a682f 100644 --- a/static/app/components/onboarding/productSelection.tsx +++ b/static/app/components/onboarding/productSelection.tsx @@ -103,6 +103,9 @@ export const platformProductAvailability = { 'go-negroni': [ProductSolution.PERFORMANCE_MONITORING], ionic: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.SESSION_REPLAY], java: [ProductSolution.PERFORMANCE_MONITORING], + 'java-log4j2': [ProductSolution.PERFORMANCE_MONITORING], + 'java-logback': [ProductSolution.PERFORMANCE_MONITORING], + 'java-spring': [ProductSolution.PERFORMANCE_MONITORING], 'java-spring-boot': [ProductSolution.PERFORMANCE_MONITORING], javascript: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.SESSION_REPLAY], 'javascript-react': [ diff --git a/static/app/components/versionHoverCard.spec.tsx b/static/app/components/versionHoverCard.spec.tsx index 2cb53e47b536f2..d9f1aed457f852 100644 --- a/static/app/components/versionHoverCard.spec.tsx +++ b/static/app/components/versionHoverCard.spec.tsx @@ -46,6 +46,8 @@ describe('VersionHoverCard', () => { await userEvent.hover(screen.getByText(release.version)); expect(await screen.findByText(deploy.environment)).toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Package'})).toBeInTheDocument(); + expect(screen.getByText(release.version.split('@')[0]!)).toBeInTheDocument(); }); it('renders authors without ids', async () => { diff --git a/static/app/components/versionHoverCard.tsx b/static/app/components/versionHoverCard.tsx index 8f89eec5e7896d..fe79173cf23542 100644 --- a/static/app/components/versionHoverCard.tsx +++ b/static/app/components/versionHoverCard.tsx @@ -6,6 +6,7 @@ import Tag from 'sentry/components/badge/tag'; import {LinkButton} from 'sentry/components/button'; import {Flex} from 'sentry/components/container/flex'; import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton'; +import {DateTime} from 'sentry/components/dateTime'; import {Hovercard} from 'sentry/components/hovercard'; import LastCommit from 'sentry/components/lastCommit'; import LoadingError from 'sentry/components/loadingError'; @@ -22,6 +23,7 @@ import {uniqueId} from 'sentry/utils/guid'; import {useDeploys} from 'sentry/utils/useDeploys'; import {useRelease} from 'sentry/utils/useRelease'; import {useRepositories} from 'sentry/utils/useRepositories'; +import {parseVersion} from 'sentry/utils/versions/parseVersion'; interface Props extends React.ComponentProps { organization: Organization; @@ -96,11 +98,11 @@ function VersionHoverCard({ return {header: null, body: null}; } + const parsedVersion = parseVersion(releaseVersion); const recentDeploysByEnvironment = deploys .toSorted( // Sorted by most recent deploy first - (a: any, b: any) => - new Date(b.dateFinished).getTime() - new Date(a.dateFinished).getTime() + (a, b) => new Date(b.dateFinished).getTime() - new Date(a.dateFinished).getTime() ) .slice(0, 3); @@ -114,25 +116,39 @@ function VersionHoverCard({ {release.newGroups}
-
- {release.commitCount}{' '} - {release.commitCount !== 1 ? t('commits ') : t('commit ')} {t('by ')}{' '} - {release.authors.length}{' '} - {release.authors.length !== 1 ? t('authors') : t('author')}{' '} -
- +
{t('Date Created')}
+
+ {parsedVersion?.package && ( + + {parsedVersion.package && ( +
+
{t('Package')}
+
{parsedVersion.package}
+
+ )} +
+
+ {release.commitCount}{' '} + {release.commitCount !== 1 ? t('commits ') : t('commit ')} {t('by ')}{' '} + {release.authors.length}{' '} + {release.authors.length !== 1 ? t('authors') : t('author')}{' '} +
+ +
+
+ )} {release.lastCommit && } {deploys.length > 0 && (
{t('Deploys')}
- {recentDeploysByEnvironment.map((deploy: any) => { + {recentDeploysByEnvironment.map(deploy => { return ( features.has('event-attachments'), - placeholder: ({organization, value}) => { + placeholder: ({organization, name, model}) => { + const value = model.getValue(name); // empty value means that this project should inherit organization settings - if (value === '') { + if (value === null) { return tct('Inherit organization settings ([organizationValue])', { organizationValue: formatStoreCrashReports(organization.storeCrashReports), }); diff --git a/static/app/gettingStartedDocs/java/java.tsx b/static/app/gettingStartedDocs/java/java.tsx index 060ee5b67d0cb5..7c243a2dec48d9 100644 --- a/static/app/gettingStartedDocs/java/java.tsx +++ b/static/app/gettingStartedDocs/java/java.tsx @@ -22,6 +22,11 @@ export enum PackageManager { SBT = 'sbt', } +export enum YesNo { + YES = 'yes', + NO = 'no', +} + const packageManagerName: Record = { [PackageManager.GRADLE]: 'Gradle', [PackageManager.MAVEN]: 'Maven', @@ -46,6 +51,19 @@ const platformOptions = { }, ], }, + opentelemetry: { + label: t('OpenTelemetry'), + items: [ + { + label: t('With OpenTelemetry'), + value: YesNo.YES, + }, + { + label: t('Without OpenTelemetry'), + value: YesNo.NO, + }, + ], + }, } satisfies BasePlatformOptions; type PlatformOptions = typeof platformOptions; @@ -116,6 +134,18 @@ const getMavenInstallSnippet = (params: Params) => ` ... `; +const getOpenTelemetryRunSnippet = (params: Params) => ` +SENTRY_PROPERTIES_FILE=sentry.properties java -javaagent:sentry-opentelemetry-agent-${getPackageVersion(params, 'sentry.java.opentelemetry-agent', '8.0.0')}.jar -jar your-application.jar +`; + +const getSentryPropertiesSnippet = (params: Params) => ` +dsn=${params.dsn.public}${ + params.isPerformanceSelected + ? ` +traces-sample-rate=1.0` + : '' +}`; + const getConfigureSnippet = (params: Params) => ` import io.sentry.Sentry; @@ -224,6 +254,26 @@ const onboarding: OnboardingConfig = { }, ] : []), + ...(params.platformOptions.opentelemetry === YesNo.YES + ? [ + { + description: tct( + "When running your application, please add our [code:sentry-opentelemetry-agent] to the [code:java] command. You can download the latest version of the [code:sentry-opentelemetry-agent.jar] from [linkMC:MavenCentral]. It's also available as a [code:ZIP] containing the [code:JAR] used on this page on [linkGH:GitHub].", + { + code: , + linkMC: ( + + ), + linkGH: ( + + ), + } + ), + language: 'bash', + code: getOpenTelemetryRunSnippet(params), + }, + ] + : []), ], additionalInfo: tct( 'If you prefer to manually upload your source code to Sentry, please refer to [link:Manually Uploading Source Context].', @@ -236,18 +286,34 @@ const onboarding: OnboardingConfig = { }, ], configure: params => [ - { - type: StepType.CONFIGURE, - description: t( - "Configure Sentry as soon as possible in your application's lifecycle:" - ), - configurations: [ - { - language: 'java', - code: getConfigureSnippet(params), + params.platformOptions.opentelemetry === YesNo.YES + ? { + type: StepType.CONFIGURE, + description: tct( + "Here's the [code:sentry.properties] file that goes with the [code:java] command above:", + { + code: , + } + ), + configurations: [ + { + language: 'java', + code: getSentryPropertiesSnippet(params), + }, + ], + } + : { + type: StepType.CONFIGURE, + description: t( + "Configure Sentry as soon as possible in your application's lifecycle:" + ), + configurations: [ + { + language: 'java', + code: getConfigureSnippet(params), + }, + ], }, - ], - }, ], verify: () => [ { diff --git a/static/app/gettingStartedDocs/java/log4j2.tsx b/static/app/gettingStartedDocs/java/log4j2.tsx index b81d4034a124c0..90e9dfc769b80a 100644 --- a/static/app/gettingStartedDocs/java/log4j2.tsx +++ b/static/app/gettingStartedDocs/java/log4j2.tsx @@ -18,6 +18,11 @@ export enum PackageManager { MAVEN = 'maven', } +export enum YesNo { + YES = 'yes', + NO = 'no', +} + const platformOptions = { packageManager: { label: t('Package Manager'), @@ -32,6 +37,19 @@ const platformOptions = { }, ], }, + opentelemetry: { + label: t('OpenTelemetry'), + items: [ + { + label: t('With OpenTelemetry'), + value: YesNo.YES, + }, + { + label: t('Without OpenTelemetry'), + value: YesNo.NO, + }, + ], + }, } satisfies BasePlatformOptions; type PlatformOptions = typeof platformOptions; @@ -102,6 +120,18 @@ const getMavenInstallSnippet = (params: Params) => ` ... `; +const getOpenTelemetryRunSnippet = (params: Params) => ` +SENTRY_PROPERTIES_FILE=sentry.properties java -javaagent:sentry-opentelemetry-agent-${getPackageVersion(params, 'sentry.java.opentelemetry-agent', '8.0.0')}.jar -jar your-application.jar +`; + +const getSentryPropertiesSnippet = (params: Params) => ` +dsn=${params.dsn.public}${ + params.isPerformanceSelected + ? ` +traces-sample-rate=1.0` + : '' +}`; + const getConsoleAppenderSnippet = (params: Params) => ` @@ -109,8 +139,12 @@ const getConsoleAppenderSnippet = (params: Params) => ` - + @@ -123,8 +157,12 @@ const getConsoleAppenderSnippet = (params: Params) => ` const getLogLevelSnippet = (params: Params) => ` -`; @@ -221,6 +259,26 @@ const onboarding: OnboardingConfig = { }, ] : []), + ...(params.platformOptions.opentelemetry === YesNo.YES + ? [ + { + description: tct( + "When running your application, please add our [code:sentry-opentelemetry-agent] to the [code:java] command. You can download the latest version of the [code:sentry-opentelemetry-agent.jar] from [linkMC:MavenCentral]. It's also available as a [code:ZIP] containing the [code:JAR] used on this page on [linkGH:GitHub].", + { + code: , + linkMC: ( + + ), + linkGH: ( + + ), + } + ), + language: 'bash', + code: getOpenTelemetryRunSnippet(params), + }, + ] + : []), ], additionalInfo: tct( 'If you prefer to manually upload your source code to Sentry, please refer to [link:Manually Uploading Source Context].', @@ -239,6 +297,25 @@ const onboarding: OnboardingConfig = { "Configure Sentry as soon as possible in your application's lifecycle:" ), configurations: [ + ...(params.platformOptions.opentelemetry === YesNo.YES + ? [ + { + type: StepType.CONFIGURE, + description: tct( + "Here's the [code:sentry.properties] file that goes with the [code:java] command above:", + { + code: , + } + ), + configurations: [ + { + language: 'java', + code: getSentryPropertiesSnippet(params), + }, + ], + }, + ] + : []), { language: 'xml', description: tct( @@ -248,15 +325,19 @@ const onboarding: OnboardingConfig = { } ), code: getConsoleAppenderSnippet(params), - additionalInfo: tct( - "You'll also need to configure your DSN (client key) if it's not already in the [code:log4j2.xml] configuration. Learn more in [link:our documentation for DSN configuration].", - { - code: , - link: ( - - ), - } - ), + ...(params.platformOptions.opentelemetry === YesNo.YES + ? {} + : { + additionalInfo: tct( + "You'll also need to configure your DSN (client key) if it's not already in the [code:log4j2.xml] configuration. Learn more in [link:our documentation for DSN configuration].", + { + code: , + link: ( + + ), + } + ), + }), }, { description: tct( diff --git a/static/app/gettingStartedDocs/java/logback.tsx b/static/app/gettingStartedDocs/java/logback.tsx index f32b0ee76a26e4..656ec9a949f939 100644 --- a/static/app/gettingStartedDocs/java/logback.tsx +++ b/static/app/gettingStartedDocs/java/logback.tsx @@ -18,6 +18,11 @@ export enum PackageManager { MAVEN = 'maven', } +export enum YesNo { + YES = 'yes', + NO = 'no', +} + const platformOptions = { packageManager: { label: t('Package Manager'), @@ -32,6 +37,19 @@ const platformOptions = { }, ], }, + opentelemetry: { + label: t('OpenTelemetry'), + items: [ + { + label: t('With OpenTelemetry'), + value: YesNo.YES, + }, + { + label: t('Without OpenTelemetry'), + value: YesNo.NO, + }, + ], + }, } satisfies BasePlatformOptions; type PlatformOptions = typeof platformOptions; @@ -102,6 +120,18 @@ const getMavenInstallSnippet = (params: Params) => ` ... `; +const getOpenTelemetryRunSnippet = (params: Params) => ` +SENTRY_PROPERTIES_FILE=sentry.properties java -javaagent:sentry-opentelemetry-agent-${getPackageVersion(params, 'sentry.java.opentelemetry-agent', '8.0.0')}.jar -jar your-application.jar +`; + +const getSentryPropertiesSnippet = (params: Params) => ` +dsn=${params.dsn.public}${ + params.isPerformanceSelected + ? ` +traces-sample-rate=1.0` + : '' +}`; + const getConsoleAppenderSnippet = (params: Params) => ` @@ -112,10 +142,14 @@ const getConsoleAppenderSnippet = (params: Params) => ` - + ${ + params.platformOptions.opentelemetry === YesNo.NO + ? ` ${params.dsn.public} - + ` + : '' + } WARN @@ -222,6 +260,26 @@ const onboarding: OnboardingConfig = { }, ] : []), + ...(params.platformOptions.opentelemetry === YesNo.YES + ? [ + { + description: tct( + "When running your application, please add our [code:sentry-opentelemetry-agent] to the [code:java] command. You can download the latest version of the [code:sentry-opentelemetry-agent.jar] from [linkMC:MavenCentral]. It's also available as a [code:ZIP] containing the [code:JAR] used on this page on [linkGH:GitHub].", + { + code: , + linkMC: ( + + ), + linkGH: ( + + ), + } + ), + language: 'bash', + code: getOpenTelemetryRunSnippet(params), + }, + ] + : []), ], additionalInfo: tct( 'If you prefer to manually upload your source code to Sentry, please refer to [link:Manually Uploading Source Context].', @@ -240,21 +298,44 @@ const onboarding: OnboardingConfig = { "Configure Sentry as soon as possible in your application's lifecycle:" ), configurations: [ + ...(params.platformOptions.opentelemetry === YesNo.YES + ? [ + { + type: StepType.CONFIGURE, + description: tct( + "Here's the [code:sentry.properties] file that goes with the [code:java] command above:", + { + code: , + } + ), + configurations: [ + { + language: 'java', + code: getSentryPropertiesSnippet(params), + }, + ], + }, + ] + : []), { language: 'xml', description: t( 'The following example configures a ConsoleAppender that logs to standard out at the INFO level, and a SentryAppender that logs to the Sentry server at the ERROR level. This only an example of a non-Sentry appender set to a different logging threshold, similar to what you may already have in your project.' ), code: getConsoleAppenderSnippet(params), - additionalInfo: tct( - "You'll also need to configure your DSN (client key) if it's not already in the [code:logback.xml] configuration. Learn more in [link:our documentation for DSN configuration].", - { - code: , - link: ( - - ), - } - ), + ...(params.platformOptions.opentelemetry === YesNo.YES + ? {} + : { + additionalInfo: tct( + "You'll also need to configure your DSN (client key) if it's not already in the [code:logback.xml] configuration. Learn more in [link:our documentation for DSN configuration].", + { + code: , + link: ( + + ), + } + ), + }), }, { description: tct( diff --git a/static/app/gettingStartedDocs/java/spring-boot.tsx b/static/app/gettingStartedDocs/java/spring-boot.tsx index 6a09f67afd806a..e1645b9e2e8ff7 100644 --- a/static/app/gettingStartedDocs/java/spring-boot.tsx +++ b/static/app/gettingStartedDocs/java/spring-boot.tsx @@ -22,6 +22,11 @@ export enum PackageManager { MAVEN = 'maven', } +export enum YesNo { + YES = 'yes', + NO = 'no', +} + const platformOptions = { packageManager: { label: t('Package Manager'), @@ -36,6 +41,19 @@ const platformOptions = { }, ], }, + opentelemetry: { + label: t('OpenTelemetry'), + items: [ + { + label: t('With OpenTelemetry'), + value: YesNo.YES, + }, + { + label: t('Without OpenTelemetry'), + value: YesNo.NO, + }, + ], + }, } satisfies BasePlatformOptions; type PlatformOptions = typeof platformOptions; @@ -106,6 +124,10 @@ const getMavenInstallSnippet = (params: Params) => ` ... `; +const getOpenTelemetryRunSnippet = (params: Params) => ` +SENTRY_AUTO_INIT=false java -javaagent:sentry-opentelemetry-agent-${getPackageVersion(params, 'sentry.java.opentelemetry-agent', '8.0.0')}.jar -jar your-application.jar +`; + const getConfigurationPropertiesSnippet = (params: Params) => ` sentry.dsn=${params.dsn.public}${ params.isPerformanceSelected @@ -199,6 +221,26 @@ const onboarding: OnboardingConfig = { language: 'xml', code: getMavenInstallSnippet(params), }, + ...(params.platformOptions.opentelemetry === YesNo.YES + ? [ + { + description: tct( + "When running your application, please add our [code:sentry-opentelemetry-agent] to the [code:java] command. You can download the latest version of the [code:sentry-opentelemetry-agent.jar] from [linkMC:MavenCentral]. It's also available as a [code:ZIP] containing the [code:JAR] used on this page on [linkGH:GitHub].", + { + code: , + linkMC: ( + + ), + linkGH: ( + + ), + } + ), + language: 'bash', + code: getOpenTelemetryRunSnippet(params), + }, + ] + : []), ], additionalInfo: (

diff --git a/static/app/gettingStartedDocs/java/spring.tsx b/static/app/gettingStartedDocs/java/spring.tsx index babb60c7acfc14..0cf19ae27868f8 100644 --- a/static/app/gettingStartedDocs/java/spring.tsx +++ b/static/app/gettingStartedDocs/java/spring.tsx @@ -27,6 +27,11 @@ export enum PackageManager { MAVEN = 'maven', } +export enum YesNo { + YES = 'yes', + NO = 'no', +} + const platformOptions = { springVersion: { label: t('Spring Version'), @@ -54,6 +59,19 @@ const platformOptions = { }, ], }, + opentelemetry: { + label: t('OpenTelemetry'), + items: [ + { + label: t('With OpenTelemetry'), + value: YesNo.YES, + }, + { + label: t('Without OpenTelemetry'), + value: YesNo.NO, + }, + ], + }, } satisfies BasePlatformOptions; type PlatformOptions = typeof platformOptions; @@ -129,6 +147,14 @@ const getMavenInstallSnippet = (params: Params) => ` ... `; +const getOpenTelemetryRunSnippet = (params: Params) => ` +SENTRY_AUTO_INIT=false java -javaagent:sentry-opentelemetry-agent-${getPackageVersion(params, 'sentry.java.opentelemetry-agent', '8.0.0')}.jar -jar your-application.jar +`; + +const getOpenTelemetryApplicationServerSnippet = (params: Params) => ` +JAVA_OPTS="$\{JAVA_OPTS} -javaagent:/somewhere/sentry-opentelemetry-agent-${getPackageVersion(params, 'sentry.java.opentelemetry-agent', '8.0.0')}.jar" +`; + const getJavaConfigSnippet = (params: Params) => ` import io.sentry.spring${ params.platformOptions.springVersion === SpringVersion.V6 ? '.jakarta' : '' @@ -170,6 +196,14 @@ try { Sentry.captureException(e) }`; +const getSentryPropertiesSnippet = (params: Params) => + `${ + params.isPerformanceSelected + ? ` +traces-sample-rate=1.0` + : '' + }`; + const onboarding: OnboardingConfig = { introduction: () => tct( @@ -246,6 +280,37 @@ const onboarding: OnboardingConfig = { }, ] : []), + ...(params.platformOptions.opentelemetry === YesNo.YES + ? [ + { + description: tct( + "When running your application, please add our [code:sentry-opentelemetry-agent] to the [code:java] command. In case you are using an application server to run your [code:.WAR] file, please add it to the [code:JAVA_OPTS] of your application server. You can download the latest version of the [code:sentry-opentelemetry-agent.jar] from [linkMC:MavenCentral]. It's also available as a [code:ZIP] containing the [code:JAR] used on this page on [linkGH:GitHub].", + { + code: , + linkMC: ( + + ), + linkGH: ( + + ), + } + ), + language: 'bash', + code: getOpenTelemetryRunSnippet(params), + }, + ] + : []), + ...(params.platformOptions.opentelemetry === YesNo.YES + ? [ + { + description: t( + 'In case of an application server, adding the Agent might look more like the following:' + ), + language: 'bash', + code: getOpenTelemetryApplicationServerSnippet(params), + }, + ] + : []), ], additionalInfo: (

@@ -313,6 +378,25 @@ const onboarding: OnboardingConfig = { }, ], }, + ...(params.isPerformanceSelected + ? [ + { + type: StepType.CONFIGURE, + description: tct( + 'Add a [code:sentry.properties] file to enable Performance:', + { + code: , + } + ), + configurations: [ + { + language: 'java', + code: getSentryPropertiesSnippet(params), + }, + ], + }, + ] + : []), ], }, ], diff --git a/static/app/gettingStartedDocs/javascript/javascript.tsx b/static/app/gettingStartedDocs/javascript/javascript.tsx index 3bcbe9fdc9b4e3..a6fe4b7e37e721 100644 --- a/static/app/gettingStartedDocs/javascript/javascript.tsx +++ b/static/app/gettingStartedDocs/javascript/javascript.tsx @@ -95,7 +95,7 @@ const result = client.getBooleanValue('my-flag', false);`, }, [IntegrationOptions.UNLEASH]: { importStatement: `import { UnleashClient } from 'unleash-proxy-client';`, - integration: 'unleashIntegration(UnleashClient)', + integration: 'unleashIntegration({unleashClientClass: UnleashClient})', sdkInit: `const unleash = new UnleashClient({ url: "https:///api/frontend", clientKey: "", diff --git a/static/app/gettingStartedDocs/minidump/minidump.spec.tsx b/static/app/gettingStartedDocs/minidump/minidump.spec.tsx index 7b92a54edb0061..aeb707c47cc637 100644 --- a/static/app/gettingStartedDocs/minidump/minidump.spec.tsx +++ b/static/app/gettingStartedDocs/minidump/minidump.spec.tsx @@ -1,10 +1,21 @@ +import {ProjectFixture} from 'sentry-fixture/project'; + import {renderWithOnboardingLayout} from 'sentry-test/onboarding/renderWithOnboardingLayout'; import {screen} from 'sentry-test/reactTestingLibrary'; import docs from './minidump'; +function renderMockRequests() { + MockApiClient.addMockResponse({ + url: '/projects/org-slug/project-slug/', + body: [ProjectFixture()], + }); +} + describe('getting started with minidump', function () { it('renders gradle docs correctly', function () { + renderMockRequests(); + renderWithOnboardingLayout(docs); // Renders main headings diff --git a/static/app/gettingStartedDocs/minidump/minidump.tsx b/static/app/gettingStartedDocs/minidump/minidump.tsx index 00140f5dc47539..c749e67adafbd1 100644 --- a/static/app/gettingStartedDocs/minidump/minidump.tsx +++ b/static/app/gettingStartedDocs/minidump/minidump.tsx @@ -3,6 +3,7 @@ import {Fragment} from 'react'; import ExternalLink from 'sentry/components/links/externalLink'; import List from 'sentry/components/list'; import ListItem from 'sentry/components/list/listItem'; +import {StoreCrashReportsConfig} from 'sentry/components/onboarding/gettingStartedDoc/storeCrashReportsConfig'; import type { Docs, DocsParams, @@ -70,7 +71,17 @@ const onboarding: OnboardingConfig = { }, ], configure: () => [], - verify: () => [], + verify: params => [ + { + title: t('Further Settings'), + description: ( + + ), + }, + ], }; const docs: Docs = { diff --git a/static/app/gettingStartedDocs/unity/unity.spec.tsx b/static/app/gettingStartedDocs/unity/unity.spec.tsx index 35a5d86522899d..3969e81c50077a 100644 --- a/static/app/gettingStartedDocs/unity/unity.spec.tsx +++ b/static/app/gettingStartedDocs/unity/unity.spec.tsx @@ -1,11 +1,22 @@ +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 docs from './unity'; +function renderMockRequests() { + MockApiClient.addMockResponse({ + url: '/projects/org-slug/project-slug/', + body: [ProjectFixture()], + }); +} + describe('unity onboarding docs', function () { it('renders docs correctly', async function () { + renderMockRequests(); + renderWithOnboardingLayout(docs, { releaseRegistry: { 'sentry.dotnet.unity': { diff --git a/static/app/gettingStartedDocs/unity/unity.tsx b/static/app/gettingStartedDocs/unity/unity.tsx index b0a8aa3a641b97..6751b00beaf6e3 100644 --- a/static/app/gettingStartedDocs/unity/unity.tsx +++ b/static/app/gettingStartedDocs/unity/unity.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import {Alert} from 'sentry/components/alert'; import ExternalLink from 'sentry/components/links/externalLink'; import {StepType} from 'sentry/components/onboarding/gettingStartedDoc/step'; +import {StoreCrashReportsConfig} from 'sentry/components/onboarding/gettingStartedDoc/storeCrashReportsConfig'; import type { Docs, OnboardingConfig, @@ -94,7 +95,7 @@ const onboarding: OnboardingConfig = { ), }, ], - verify: () => [ + verify: params => [ { type: StepType.VERIFY, description: t( @@ -130,6 +131,15 @@ const onboarding: OnboardingConfig = { ), }, + { + title: t('Further Settings'), + description: ( + + ), + }, ], }; diff --git a/static/app/gettingStartedDocs/unreal/unreal.spec.tsx b/static/app/gettingStartedDocs/unreal/unreal.spec.tsx index 1183a45c7db3c7..46f659cb460cd7 100644 --- a/static/app/gettingStartedDocs/unreal/unreal.spec.tsx +++ b/static/app/gettingStartedDocs/unreal/unreal.spec.tsx @@ -1,10 +1,21 @@ +import {ProjectFixture} from 'sentry-fixture/project'; + import {renderWithOnboardingLayout} from 'sentry-test/onboarding/renderWithOnboardingLayout'; import {screen} from 'sentry-test/reactTestingLibrary'; import docs from './unreal'; +function renderMockRequests() { + MockApiClient.addMockResponse({ + url: '/projects/org-slug/project-slug/', + body: [ProjectFixture()], + }); +} + describe('getting started with unreal', function () { it('renders docs correctly', function () { + renderMockRequests(); + renderWithOnboardingLayout(docs); // Renders main headings diff --git a/static/app/gettingStartedDocs/unreal/unreal.tsx b/static/app/gettingStartedDocs/unreal/unreal.tsx index bb3570ca8db814..8c5de8938dfd80 100644 --- a/static/app/gettingStartedDocs/unreal/unreal.tsx +++ b/static/app/gettingStartedDocs/unreal/unreal.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import {Alert} from 'sentry/components/alert'; import ExternalLink from 'sentry/components/links/externalLink'; import {StepType} from 'sentry/components/onboarding/gettingStartedDoc/step'; +import {StoreCrashReportsConfig} from 'sentry/components/onboarding/gettingStartedDoc/storeCrashReportsConfig'; import type { Docs, DocsParams, @@ -210,6 +211,15 @@ const onboarding: OnboardingConfig = { ), }, + { + title: t('Further Settings'), + description: ( + + ), + }, ], }; diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx index 87a837ec86201b..092ce739125cd1 100644 --- a/static/app/types/project.tsx +++ b/static/app/types/project.tsx @@ -59,6 +59,7 @@ export type Project = { scrapeJavaScript: boolean; scrubIPAddresses: boolean; sensitiveFields: string[]; + storeCrashReports: number | null; subjectTemplate: string; team: Team; teams: Team[]; diff --git a/static/app/types/system.tsx b/static/app/types/system.tsx index a783bf6293814f..3718869907b7d4 100644 --- a/static/app/types/system.tsx +++ b/static/app/types/system.tsx @@ -1,6 +1,7 @@ import type {Theme} from '@emotion/react'; import type {FocusTrap} from 'focus-trap'; +import type {ApiResult} from 'sentry/api'; import type {exportedGlobals} from 'sentry/bootstrap/exportGlobals'; import type {ParntershipAgreementType} from './hooks'; @@ -77,7 +78,15 @@ declare global { /** * Is populated with promises/strings of commonly used data. */ - __sentry_preload: Record; + __sentry_preload: { + orgSlug?: string; + organization?: Promise; + organization_fallback?: Promise; + projects?: Promise; + projects_fallback?: Promise; + teams?: Promise; + teams_fallback?: Promise; + }; // typing currently used for demo add on // TODO: improve typing diff --git a/static/app/utils/dashboards/issueAssignee.tsx b/static/app/utils/dashboards/issueAssignee.tsx new file mode 100644 index 00000000000000..a9c722ab702dcf --- /dev/null +++ b/static/app/utils/dashboards/issueAssignee.tsx @@ -0,0 +1,58 @@ +import styled from '@emotion/styled'; + +import ActorAvatar from 'sentry/components/avatar/actorAvatar'; +import {Tooltip} from 'sentry/components/tooltip'; +import {IconUser} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import GroupStore from 'sentry/stores/groupStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; + +interface IssueAssigneeProps { + groupId: string; +} + +export function IssueAssignee({groupId}: IssueAssigneeProps) { + const groups = useLegacyStore(GroupStore); + const group = groups.find(item => item.id === groupId); + const assignedTo = group?.assignedTo; + + if (assignedTo) { + return ( + + + + ); + } + + return ( + + + + + + ); +} + +const ActorContainer = styled('div')` + display: flex; + justify-content: left; + padding-left: 18px; + align-items: center; + height: 22px; +`; + +const UnassignedContainer = styled('div')` + display: flex; + justify-content: left; + padding-left: 20px; + align-items: center; + height: 22px; +`; diff --git a/static/app/utils/dashboards/issueFieldRenderers.spec.tsx b/static/app/utils/dashboards/issueFieldRenderers.spec.tsx index c91cf41ec56680..dd8844344c7544 100644 --- a/static/app/utils/dashboards/issueFieldRenderers.spec.tsx +++ b/static/app/utils/dashboards/issueFieldRenderers.spec.tsx @@ -84,21 +84,16 @@ describe('getIssueFieldRenderer', function () { }), ]); - const group = GroupFixture({project}); - GroupStore.add([ - { - ...group, - owners: [ - {owner: 'user:1', type: 'suspectCommit', date_added: '2020-01-01T00:00:00'}, - ], - assignedTo: { - email: 'test@sentry.io', - type: 'user', - id: '1', - name: 'Test User', - }, + const group = GroupFixture({ + project, + assignedTo: { + email: 'test@sentry.io', + type: 'user', + id: '1', + name: 'Test User', }, - ]); + }); + GroupStore.add([group]); const renderer = getIssueFieldRenderer('assignee'); render( @@ -107,11 +102,8 @@ describe('getIssueFieldRenderer', function () { organization, }) as React.ReactElement ); - expect(screen.getByText('TU')).toBeInTheDocument(); await userEvent.hover(screen.getByText('TU')); expect(await screen.findByText('Assigned to Test User')).toBeInTheDocument(); - expect(screen.getByText('Based on')).toBeInTheDocument(); - expect(screen.getByText('commit data')).toBeInTheDocument(); }); it('can render counts', async function () { diff --git a/static/app/utils/dashboards/issueFieldRenderers.tsx b/static/app/utils/dashboards/issueFieldRenderers.tsx index b2be0550460f40..fec6242b3b9a8c 100644 --- a/static/app/utils/dashboards/issueFieldRenderers.tsx +++ b/static/app/utils/dashboards/issueFieldRenderers.tsx @@ -4,16 +4,15 @@ import styled from '@emotion/styled'; import type {Location} from 'history'; import Count from 'sentry/components/count'; -import DeprecatedAssigneeSelector from 'sentry/components/deprecatedAssigneeSelector'; import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import {getRelativeSummary} from 'sentry/components/timeRangeSelector/utils'; import {Tooltip} from 'sentry/components/tooltip'; import {DEFAULT_STATS_PERIOD} from 'sentry/constants'; import {t} from 'sentry/locale'; -import MemberListStore from 'sentry/stores/memberListStore'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; +import {IssueAssignee} from 'sentry/utils/dashboards/issueAssignee'; import type {EventData} from 'sentry/utils/discover/eventView'; import EventView from 'sentry/utils/discover/eventView'; import {SavedQueryDatasets} from 'sentry/utils/discover/types'; @@ -93,14 +92,7 @@ const SPECIAL_FIELDS: SpecialFields = { }, assignee: { sortField: null, - renderFunc: data => { - const memberList = MemberListStore.getAll(); - return ( - - - - ); - }, + renderFunc: data => , }, lifetimeEvents: { sortField: null, @@ -302,17 +294,6 @@ const Divider = styled('div')` background-color: ${p => p.theme.innerBorder}; `; -const ActorContainer = styled('div')` - display: flex; - justify-content: left; - margin-left: 18px; - /* IconUser is the only one with 20px. We are setting 24px here to make the height consistent */ - height: 24px; - :hover { - cursor: default; - } -`; - const LinksContainer = styled('span')` white-space: nowrap; `; diff --git a/static/app/utils/getPreloadedData.spec.tsx b/static/app/utils/getPreloadedData.spec.tsx index abb126107cc5f0..f940a8fecd3fff 100644 --- a/static/app/utils/getPreloadedData.spec.tsx +++ b/static/app/utils/getPreloadedData.spec.tsx @@ -2,20 +2,20 @@ import {getPreloadedDataPromise} from './getPreloadedData'; describe('getPreloadedDataPromise', () => { beforeEach(() => { - (window as any).__sentry_preload = { + window.__sentry_preload = { orgSlug: 'slug', }; }); it('should register fallback promise', async () => { const fallback = jest.fn(() => Promise.resolve('fallback')); - const result = await getPreloadedDataPromise('name', 'slug', fallback); + const result = await getPreloadedDataPromise('organization', 'slug', fallback as any); expect(result).toBe('fallback'); - expect((window as any).__sentry_preload.name_fallback).toBeInstanceOf(Promise); + expect(window.__sentry_preload.organization_fallback).toBeInstanceOf(Promise); }); it('should only call fallback on failure', async () => { - (window as any).__sentry_preload.name = Promise.resolve('success'); + window.__sentry_preload.organization = Promise.resolve('success') as any; const fallback = jest.fn(); - const result = await getPreloadedDataPromise('name', 'slug', fallback, true); + const result = await getPreloadedDataPromise('organization', 'slug', fallback, true); expect(result).toBe('success'); expect(fallback).not.toHaveBeenCalled(); }); diff --git a/static/app/utils/getPreloadedData.ts b/static/app/utils/getPreloadedData.ts index c1782edc0a3799..408c99a9526f28 100644 --- a/static/app/utils/getPreloadedData.ts +++ b/static/app/utils/getPreloadedData.ts @@ -1,16 +1,18 @@ +import type {ApiResult} from 'sentry/api'; + export async function getPreloadedDataPromise( - name: string, + name: 'organization' | 'projects' | 'teams', slug: string, - fallback: () => Promise, + fallback: () => Promise, usePreload?: boolean -) { - const data = (window as any).__sentry_preload; +): Promise { + const data = window.__sentry_preload; /** * Save the fallback promise to `__sentry_preload` to allow the sudo modal to wait * for the promise to resolve */ const wrappedFallback = () => { - const fallbackAttribute = `${name}_fallback`; + const fallbackAttribute = `${name}_fallback` as const; const promise = fallback(); if (data) { data[fallbackAttribute] = promise; diff --git a/static/app/utils/profiling/renderers/UIFramesRenderer.tsx b/static/app/utils/profiling/renderers/UIFramesRenderer.tsx index 63180a6de93e92..fb0abc71f09c80 100644 --- a/static/app/utils/profiling/renderers/UIFramesRenderer.tsx +++ b/static/app/utils/profiling/renderers/UIFramesRenderer.tsx @@ -6,14 +6,12 @@ import type {UIFrameNode, UIFrames} from 'sentry/utils/profiling/uiFrames'; import {upperBound} from '../gl/utils'; -export interface UIFramesRendererConstructor { - new ( - canvas: HTMLCanvasElement, - uiFrames: UIFrames, - theme: FlamegraphTheme, - options?: {draw_border: boolean} - ): UIFramesRenderer; -} +export type UIFramesRendererConstructor = new ( + canvas: HTMLCanvasElement, + uiFrames: UIFrames, + theme: FlamegraphTheme, + options?: {draw_border: boolean} +) => UIFramesRenderer; export abstract class UIFramesRenderer { ctx: CanvasRenderingContext2D | WebGLRenderingContext | null = null; diff --git a/static/app/utils/profiling/renderers/flamegraphRenderer.tsx b/static/app/utils/profiling/renderers/flamegraphRenderer.tsx index 2f60b22e883b34..d07f68e2eaa5a4 100644 --- a/static/app/utils/profiling/renderers/flamegraphRenderer.tsx +++ b/static/app/utils/profiling/renderers/flamegraphRenderer.tsx @@ -17,14 +17,12 @@ export const DEFAULT_FLAMEGRAPH_RENDERER_OPTIONS: FlamegraphRendererOptions = { draw_border: false, }; -export interface FlamegraphRendererConstructor { - new ( - canvas: HTMLCanvasElement, - flamegraph: Flamegraph, - theme: FlamegraphTheme, - options?: FlamegraphRendererOptions - ): FlamegraphRenderer; -} +export type FlamegraphRendererConstructor = new ( + canvas: HTMLCanvasElement, + flamegraph: Flamegraph, + theme: FlamegraphTheme, + options?: FlamegraphRendererOptions +) => FlamegraphRenderer; export abstract class FlamegraphRenderer { ctx: CanvasRenderingContext2D | WebGLRenderingContext | null = null; diff --git a/static/app/utils/statics-setup.tsx b/static/app/utils/statics-setup.tsx index 6414e08c63724d..3ece75819321c9 100644 --- a/static/app/utils/statics-setup.tsx +++ b/static/app/utils/statics-setup.tsx @@ -1,7 +1,7 @@ /* eslint no-native-reassign:0 */ // biome-ignore lint/style/noVar: Not required -declare var __webpack_public_path__: string; +declare var __webpack_public_path__: string; // eslint-disable-line no-var /** * Set the webpack public path at runtime. This is necessary so that imports diff --git a/static/app/utils/string/isUUID.spec.tsx b/static/app/utils/string/isUUID.spec.tsx new file mode 100644 index 00000000000000..6c5f2760b48264 --- /dev/null +++ b/static/app/utils/string/isUUID.spec.tsx @@ -0,0 +1,25 @@ +import {isUUID} from 'sentry/utils/string/isUUID'; + +describe('isUUID', () => { + test('valid UUIDs should return true', () => { + expect(isUUID('123e4567-e89b-12d3-a456-426614174000')).toBe(true); + expect(isUUID('00000000-0000-0000-0000-000000000000')).toBe(true); + expect(isUUID('ffffffff-ffff-ffff-ffff-ffffffffffff')).toBe(true); + }); + + test('invalid UUIDs should return false', () => { + expect(isUUID('123e4567e89b12d3a456426614174000')).toBe(false); // Missing hyphens + expect(isUUID('123e4567-e89b-12d3-a456-42661417400')).toBe(false); // Too short + expect(isUUID('123e4567-e89b-12d3-a456-42661417400000')).toBe(false); // Too long + expect(isUUID('g23e4567-e89b-12d3-a456-426614174000')).toBe(false); // Invalid character + expect(isUUID('123e4567-e89b-12d3-a456-42661417400g')).toBe(false); // Invalid character at end + expect(isUUID('123e4567-e89b-12d3-a456-42661417400-')).toBe(false); // Hyphen at end + }); + + test('edge cases should return false', () => { + expect(isUUID('')).toBe(false); // Empty string + expect(isUUID(' ')).toBe(false); // Space + expect(isUUID('123e4567-e89b-12d3-a456-426614174000\n')).toBe(false); // Valid UUID with newline + expect(isUUID('123e4567-e89b-12d3-a456-426614174000 ')).toBe(false); // Valid UUID with trailing space + }); +}); diff --git a/static/app/utils/string/isUUID.tsx b/static/app/utils/string/isUUID.tsx new file mode 100644 index 00000000000000..acebd96810ab3c --- /dev/null +++ b/static/app/utils/string/isUUID.tsx @@ -0,0 +1,4 @@ +export function isUUID(uuid: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); +} diff --git a/static/app/utils/versions/formatVersion.tsx b/static/app/utils/versions/formatVersion.tsx index 967984425ea501..ce22a9716e1547 100644 --- a/static/app/utils/versions/formatVersion.tsx +++ b/static/app/utils/versions/formatVersion.tsx @@ -1,16 +1,16 @@ -import {Release} from '@sentry/release-parser'; +import {parseVersion} from './parseVersion'; export const formatVersion = (rawVersion: string, withPackage = false) => { - try { - const parsedVersion = new Release(rawVersion); - const versionToDisplay = parsedVersion.describe(); + const parsedVersion = parseVersion(rawVersion); + if (!parsedVersion) { + return rawVersion; + } - if (versionToDisplay.length) { - return `${versionToDisplay}${withPackage && parsedVersion.package ? `, ${parsedVersion.package}` : ''}`; - } + const versionToDisplay = parsedVersion.describe(); - return rawVersion; - } catch { - return rawVersion; + if (versionToDisplay.length) { + return `${versionToDisplay}${withPackage && parsedVersion.package ? `, ${parsedVersion.package}` : ''}`; } + + return rawVersion; }; diff --git a/static/app/utils/versions/isSemverRelease.tsx b/static/app/utils/versions/isSemverRelease.tsx index 23fedb2d298f84..7dd0c207248383 100644 --- a/static/app/utils/versions/isSemverRelease.tsx +++ b/static/app/utils/versions/isSemverRelease.tsx @@ -1,10 +1,6 @@ -import {Release} from '@sentry/release-parser'; +import {parseVersion} from 'sentry/utils/versions/parseVersion'; export const isSemverRelease = (rawVersion: string): boolean => { - try { - const parsedVersion = new Release(rawVersion); - return !!parsedVersion.versionParsed; - } catch { - return false; - } + const parsedVersion = parseVersion(rawVersion); + return !!parsedVersion?.versionParsed; }; diff --git a/static/app/utils/versions/parseVersion.tsx b/static/app/utils/versions/parseVersion.tsx new file mode 100644 index 00000000000000..7625f335180851 --- /dev/null +++ b/static/app/utils/versions/parseVersion.tsx @@ -0,0 +1,10 @@ +import {Release} from '@sentry/release-parser'; + +export function parseVersion(rawVersion: string): Release | null { + try { + const parsedVersion = new Release(rawVersion); + return parsedVersion; + } catch { + return null; + } +} diff --git a/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx index 906ebabfc6b7fb..436ab8bc0f2ab8 100644 --- a/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx +++ b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx @@ -5,10 +5,12 @@ import {hasActiveIncident} from 'sentry/views/alerts/list/rules/utils'; import { type CombinedAlerts, CombinedAlertType, + type CronRule, type IssueAlert, type MetricAlert, type UptimeAlert, } from 'sentry/views/alerts/types'; +import {scheduleAsText} from 'sentry/views/monitors/utils/scheduleAsText'; interface Props { rule: CombinedAlerts; @@ -18,12 +20,21 @@ interface Props { * Displays the time since the last uptime incident given an uptime alert rule */ function LastUptimeIncident({rule}: {rule: UptimeAlert}) { - // TODO(davidenwang): Once we have a lastTriggered field returned from backend, display that info here + // TODO(davidenwang): Once we have a lastTriggered field returned from + // backend, display that info here return tct('Actively monitoring every [interval]', { interval: getDuration(rule.intervalSeconds), }); } +function LastCronMonitorIncident({rule}: {rule: CronRule}) { + // TODO(evanpurkhiser): Would probably be better if we had a way to get the + // most recent incident. + return tct('Expected [interval]', { + interval: scheduleAsText(rule.config), + }); +} + /** * Displays the last time an issue alert was triggered */ @@ -68,6 +79,8 @@ export default function AlertLastIncidentActivationInfo({rule}: Props) { switch (rule.type) { case CombinedAlertType.UPTIME: return ; + case CombinedAlertType.CRONS: + return ; case CombinedAlertType.ISSUE: return ; case CombinedAlertType.METRIC: diff --git a/static/app/views/alerts/list/rules/alertRulesList.spec.tsx b/static/app/views/alerts/list/rules/alertRulesList.spec.tsx index ea4f0569a1812a..efe910206abed6 100644 --- a/static/app/views/alerts/list/rules/alertRulesList.spec.tsx +++ b/static/app/views/alerts/list/rules/alertRulesList.spec.tsx @@ -526,7 +526,6 @@ describe('AlertRulesList', () => { expect(await screen.findByText('Uptime Rule')).toBeInTheDocument(); expect(await screen.findByText('Auto Detected')).toBeInTheDocument(); - expect(await screen.findByText('Up')).toBeInTheDocument(); }); it('deletes an uptime rule', async () => { diff --git a/static/app/views/alerts/list/rules/alertRulesList.tsx b/static/app/views/alerts/list/rules/alertRulesList.tsx index 8561c5345ef63b..d423caf667b8f7 100644 --- a/static/app/views/alerts/list/rules/alertRulesList.tsx +++ b/static/app/views/alerts/list/rules/alertRulesList.tsx @@ -140,10 +140,11 @@ function AlertRulesList() { }; const handleDeleteRule = async (projectId: string, rule: CombinedAlerts) => { - const deleteEndpoints = { + const deleteEndpoints: Record = { [CombinedAlertType.ISSUE]: `/projects/${organization.slug}/${projectId}/rules/${rule.id}/`, [CombinedAlertType.METRIC]: `/organizations/${organization.slug}/alert-rules/${rule.id}/`, [CombinedAlertType.UPTIME]: `/projects/${organization.slug}/${projectId}/uptime/${rule.id}/`, + [CombinedAlertType.CRONS]: `/projects/${organization.slug}/${projectId}/monitors/${rule.id}/`, }; try { @@ -165,7 +166,11 @@ function AlertRulesList() { const ruleList = ruleListResponse.filter(defined); const projectsFromResults = uniq( ruleList.flatMap(rule => - rule.type === CombinedAlertType.UPTIME ? [rule.projectSlug] : rule.projects + rule.type === CombinedAlertType.UPTIME + ? [rule.projectSlug] + : rule.type === CombinedAlertType.CRONS + ? [rule.project.slug] + : rule.projects ) ); const ruleListPageLinks = getResponseHeader?.('Link'); diff --git a/static/app/views/alerts/list/rules/combinedAlertBadge.tsx b/static/app/views/alerts/list/rules/combinedAlertBadge.tsx index f456313b86d108..12d29badae8064 100644 --- a/static/app/views/alerts/list/rules/combinedAlertBadge.tsx +++ b/static/app/views/alerts/list/rules/combinedAlertBadge.tsx @@ -1,6 +1,7 @@ import AlertBadge from 'sentry/components/badge/alertBadge'; import {Tooltip} from 'sentry/components/tooltip'; import {t, tct} from 'sentry/locale'; +import {getAggregateEnvStatus} from 'sentry/views/alerts/rules/crons/utils'; import {UptimeMonitorStatus} from 'sentry/views/alerts/rules/uptime/types'; import { type CombinedAlerts, @@ -8,6 +9,7 @@ import { IncidentStatus, } from 'sentry/views/alerts/types'; import {isIssueAlert} from 'sentry/views/alerts/utils'; +import {MonitorStatus} from 'sentry/views/monitors/types'; interface Props { rule: CombinedAlerts; @@ -31,6 +33,25 @@ const UptimeStatusText: Record< }, }; +const CronsStatusText: Record< + MonitorStatus, + {statusText: string; disabled?: boolean; incidentStatus?: IncidentStatus} +> = { + [MonitorStatus.ACTIVE]: { + statusText: t('Active'), + incidentStatus: IncidentStatus.CLOSED, + }, + [MonitorStatus.OK]: {statusText: t('Ok'), incidentStatus: IncidentStatus.CLOSED}, + [MonitorStatus.ERROR]: { + statusText: t('Failing'), + incidentStatus: IncidentStatus.CRITICAL, + }, + [MonitorStatus.DISABLED]: { + statusText: t('Disabled'), + disabled: true, + }, +}; + /** * Takes in an alert rule (metric or issue) and renders the * appropriate tooltip and AlertBadge @@ -45,6 +66,16 @@ export default function CombinedAlertBadge({rule}: Props) { ); } + if (rule.type === CombinedAlertType.CRONS) { + const envStatus = getAggregateEnvStatus(rule.environments); + const {statusText, incidentStatus, disabled} = CronsStatusText[envStatus]; + return ( + + + + ); + } + return ( (''); const isUptime = rule.type === CombinedAlertType.UPTIME; + const isCron = rule.type === CombinedAlertType.CRONS; - const slug = isUptime ? rule.projectSlug : rule.projects[0]!; + const slug = isUptime + ? rule.projectSlug + : isCron + ? rule.project.slug + : rule.projects[0]!; const editKey = { [CombinedAlertType.ISSUE]: 'rules', @@ -99,6 +101,7 @@ function RuleListRow({ [CombinedAlertType.ISSUE]: ['edit', 'duplicate', 'delete'], [CombinedAlertType.METRIC]: ['edit', 'duplicate', 'delete'], [CombinedAlertType.UPTIME]: ['edit', 'delete'], + [CombinedAlertType.CRONS]: ['edit', 'delete'], }; const actions: MenuItemProps[] = [ @@ -216,20 +219,25 @@ function RuleListRow({ ) : null; + function ruleUrl() { + switch (rule.type) { + case CombinedAlertType.METRIC: + return `/organizations/${orgId}/alerts/rules/details/${rule.id}/`; + case CombinedAlertType.CRONS: + return `/organizations/${orgId}/alerts/rules/crons/${rule.project.slug}/${rule.id}/details/`; + case CombinedAlertType.UPTIME: + return `/organizations/${orgId}/alerts/rules/uptime/${rule.projectSlug}/${rule.id}/details/`; + default: + return `/organizations/${orgId}/alerts/rules/${rule.projects[0]}/${rule.id}/details/`; + } + } + return ( - + {rule.name} {titleBadge} @@ -242,17 +250,11 @@ function RuleListRow({ - - {isUptime ? ( - rule.status === UptimeMonitorStatus.FAILED ? ( - t('Down') - ) : ( - t('Up') - ) - ) : ( + {!isUptime && !isCron && ( + - )} - + + )} diff --git a/static/app/views/alerts/list/rules/utils.tsx b/static/app/views/alerts/list/rules/utils.tsx index a2c3ccf359b0f3..d52dc4aacbe63a 100644 --- a/static/app/views/alerts/list/rules/utils.tsx +++ b/static/app/views/alerts/list/rules/utils.tsx @@ -17,6 +17,9 @@ export function getActor(rule: CombinedAlerts): Actor | null { if (rule.type === CombinedAlertType.UPTIME) { return rule.owner; } + if (rule.type === CombinedAlertType.CRONS) { + return rule.owner; + } const ownerId = rule.owner?.split(':')[1]; return ownerId ? {type: 'team' as Actor['type'], id: ownerId, name: ''} : null; diff --git a/static/app/views/alerts/rules/crons/utils.tsx b/static/app/views/alerts/rules/crons/utils.tsx new file mode 100644 index 00000000000000..96aec6a38c78fd --- /dev/null +++ b/static/app/views/alerts/rules/crons/utils.tsx @@ -0,0 +1,19 @@ +import {type MonitorEnvironment, MonitorStatus} from 'sentry/views/monitors/types'; + +const MONITOR_STATUS_PRECEDENT = [ + MonitorStatus.ERROR, + MonitorStatus.OK, + MonitorStatus.ACTIVE, + MonitorStatus.DISABLED, +]; + +/** + * Get the aggregate MonitorStatus of a set of monitor environments. + */ +export function getAggregateEnvStatus(environments: MonitorEnvironment[]): MonitorStatus { + const status = MONITOR_STATUS_PRECEDENT.find(s => + environments.some(env => env.status === s) + ); + + return status ?? MonitorStatus.ACTIVE; +} diff --git a/static/app/views/alerts/rules/issue/setupMessagingIntegrationButton.tsx b/static/app/views/alerts/rules/issue/setupMessagingIntegrationButton.tsx index 6eef65e5e55403..30295401b86bd7 100644 --- a/static/app/views/alerts/rules/issue/setupMessagingIntegrationButton.tsx +++ b/static/app/views/alerts/rules/issue/setupMessagingIntegrationButton.tsx @@ -63,7 +63,7 @@ function SetupMessagingIntegrationButton({ messagingIntegrationsQuery.isError || integrationProvidersQuery.some(({isPending}) => isPending) || integrationProvidersQuery.some(({isError}) => isError) || - integrationProvidersQuery[0]!.data == null + integrationProvidersQuery[0]!.data === undefined ) { return null; } diff --git a/static/app/views/alerts/types.tsx b/static/app/views/alerts/types.tsx index 001c8adb102f0c..2888f7c13488df 100644 --- a/static/app/views/alerts/types.tsx +++ b/static/app/views/alerts/types.tsx @@ -2,6 +2,7 @@ import type {IssueAlertRule} from 'sentry/types/alerts'; import type {User} from 'sentry/types/user'; import type {MetricRule} from 'sentry/views/alerts/rules/metric/types'; import type {UptimeRule} from 'sentry/views/alerts/rules/uptime/types'; +import type {Monitor} from 'sentry/views/monitors/types'; type Data = [number, {count: number}[]][]; @@ -89,7 +90,7 @@ export enum CombinedAlertType { METRIC = 'alert_rule', ISSUE = 'rule', UPTIME = 'uptime', - CRONS = 'crons', + CRONS = 'monitor', } export interface IssueAlert extends IssueAlertRule { @@ -105,9 +106,13 @@ export interface UptimeAlert extends UptimeRule { type: CombinedAlertType.UPTIME; } +export interface CronRule extends Monitor { + type: CombinedAlertType.CRONS; +} + export type CombinedMetricIssueAlerts = IssueAlert | MetricAlert; -export type CombinedAlerts = CombinedMetricIssueAlerts | UptimeAlert; +export type CombinedAlerts = CombinedMetricIssueAlerts | UptimeAlert | CronRule; export type Anomaly = { anomaly: {anomaly_score: number; anomaly_type: AnomalyType}; diff --git a/static/app/views/dashboards/dashboard.spec.tsx b/static/app/views/dashboards/dashboard.spec.tsx index e2c52b7ff6089b..0313b0dcda57f0 100644 --- a/static/app/views/dashboards/dashboard.spec.tsx +++ b/static/app/views/dashboards/dashboard.spec.tsx @@ -2,6 +2,7 @@ import {LocationFixture} from 'sentry-fixture/locationFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {RouterFixture} from 'sentry-fixture/routerFixture'; import {TagsFixture} from 'sentry-fixture/tags'; +import {UserFixture} from 'sentry-fixture/user'; import {WidgetFixture} from 'sentry-fixture/widget'; import {initializeOrg} from 'sentry-test/initializeOrg'; @@ -106,12 +107,12 @@ describe('Dashboards > Dashboard', () => { project: { id: '3', }, - owners: [ - { - type: 'ownershipRule', - owner: 'user:2', - }, - ], + assignedTo: { + email: 'test@sentry.io', + type: 'user', + id: '1', + name: 'Test User', + }, }, ], }); @@ -403,16 +404,23 @@ describe('Dashboards > Dashboard', () => { expect(screen.getByText('Test Issue Widget')).toBeInTheDocument(); }); - it('renders suggested assignees', async () => { + it('renders assignee', async () => { + MemberListStore.loadInitialData([ + UserFixture({ + name: 'Test User', + email: 'test@sentry.io', + avatar: { + avatarType: 'letter_avatar', + avatarUuid: null, + }, + }), + ]); const mockDashboardWithIssueWidget = { ...mockDashboard, widgets: [{...issueWidget}], }; mount(mockDashboardWithIssueWidget, organization); - expect(await screen.findByText('T')).toBeInTheDocument(); - await userEvent.hover(screen.getByText('T')); - expect(await screen.findByText('Suggestion: test@sentry.io')).toBeInTheDocument(); - expect(screen.getByText('Matching Issue Owners Rule')).toBeInTheDocument(); + expect(await screen.findByTitle('Test User')).toBeInTheDocument(); }); }); diff --git a/static/app/views/dashboards/datasetConfig/spans.tsx b/static/app/views/dashboards/datasetConfig/spans.tsx index 08b03f3b816bfe..6f913d004fa401 100644 --- a/static/app/views/dashboards/datasetConfig/spans.tsx +++ b/static/app/views/dashboards/datasetConfig/spans.tsx @@ -21,7 +21,6 @@ import { } from 'sentry/utils/discover/genericDiscoverQuery'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {ALLOWED_EXPLORE_VISUALIZE_AGGREGATES} from 'sentry/utils/fields'; -import localStorage from 'sentry/utils/localStorage'; import type {MEPState} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; import type {OnDemandControlContext} from 'sentry/utils/performance/contexts/onDemandControl'; import { @@ -38,7 +37,6 @@ import {getSeriesRequestData} from 'sentry/views/dashboards/datasetConfig/utils/ import {DisplayType, type Widget, type WidgetQuery} from 'sentry/views/dashboards/types'; import {eventViewFromWidget} from 'sentry/views/dashboards/utils'; import SpansSearchBar from 'sentry/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar'; -import {DASHBOARD_RPC_TOGGLE_KEY} from 'sentry/views/dashboards/widgetBuilder/components/rpcToggle'; import type {FieldValueOption} from 'sentry/views/discover/table/queryField'; import {FieldValueKind} from 'sentry/views/discover/table/types'; import {generateFieldOptions} from 'sentry/views/discover/utils'; @@ -207,14 +205,12 @@ function getEventsRequest( const url = `/organizations/${organization.slug}/events/`; const eventView = eventViewFromWidget('', query, pageFilters); - const useRpc = localStorage.getItem(DASHBOARD_RPC_TOGGLE_KEY) === 'true'; - const params: DiscoverQueryRequestParams = { per_page: limit, cursor, referrer, dataset: DiscoverDatasets.SPANS_EAP, - useRpc: useRpc ? '1' : undefined, + useRpc: '1', ...queryExtras, }; @@ -278,8 +274,7 @@ function getSeriesRequest( referrer ); - const useRpc = localStorage.getItem(DASHBOARD_RPC_TOGGLE_KEY) === 'true'; - requestData.useRpc = useRpc; + requestData.useRpc = true; return doEventsRequest(api, requestData); } diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index a1a2f51b2470fe..135217a4172afe 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -292,8 +292,14 @@ class DashboardDetail extends Component { location, router, } = this.props; - const {seriesData, tableData, pageLinks, totalIssuesCount, seriesResultsType} = - this.state; + const { + seriesData, + tableData, + pageLinks, + totalIssuesCount, + seriesResultsType, + confidence, + } = this.state; if (isWidgetViewerPath(location.pathname)) { const widget = defined(widgetId) && @@ -358,6 +364,7 @@ class DashboardDetail extends Component { return; } }, + confidence, }); trackAnalytics('dashboards_views.widget_viewer.open', { organization, diff --git a/static/app/views/dashboards/editAccessSelector.tsx b/static/app/views/dashboards/editAccessSelector.tsx index 934dc42f388141..6e89ab22bce388 100644 --- a/static/app/views/dashboards/editAccessSelector.tsx +++ b/static/app/views/dashboards/editAccessSelector.tsx @@ -5,7 +5,8 @@ import isEqual from 'lodash/isEqual'; import sortBy from 'lodash/sortBy'; import {hasEveryAccess} from 'sentry/components/acl/access'; -import AvatarList from 'sentry/components/avatar/avatarList'; +import Avatar from 'sentry/components/avatar'; +import AvatarList, {CollapsedAvatars} from 'sentry/components/avatar/avatarList'; import TeamAvatar from 'sentry/components/avatar/teamAvatar'; import Badge from 'sentry/components/badge/badge'; import FeatureBadge from 'sentry/components/badge/featureBadge'; @@ -64,12 +65,17 @@ function EditAccessSelector({ const [selectedOptions, setSelectedOptions] = useState([]); const [stagedOptions, setStagedOptions] = useState([]); const [isMenuOpen, setMenuOpen] = useState(false); + const [isCollapsedAvatarTooltipOpen, setIsCollapsedAvatarTooltipOpen] = + useState(false); const {teams: selectedTeam} = useTeamsById({ ids: selectedOptions[1] && selectedOptions[1] !== '_allUsers' ? [selectedOptions[1]] : [], }); + const {teams: allSelectedTeams} = useTeamsById({ + ids: selectedOptions.filter(option => option !== '_allUsers'), + }); // Gets selected options for the dropdown from dashboard object useEffect(() => { @@ -131,6 +137,52 @@ function EditAccessSelector({ }; } + // Creates tooltip for the + bubble in avatar list + const renderCollapsedAvatarTooltip = () => { + const permissions = getDashboardPermissions(); + if (permissions.teamsWithEditAccess.length > 1) { + return ( + + {allSelectedTeams.map((team, index) => ( + + +

#{team.name}
+ + ))} + + ); + } + return null; + }; + + const renderCollapsedAvatars = (avatarSize: number, numCollapsedAvatars: number) => { + return ( + +
setIsCollapsedAvatarTooltipOpen(true)} + onMouseLeave={() => setIsCollapsedAvatarTooltipOpen(false)} + > + + {numCollapsedAvatars < 99 && +} + {numCollapsedAvatars} + +
+
+ ); + }; + const makeCreatorOption = useCallback( () => ({ value: '_creator', @@ -189,6 +241,7 @@ function EditAccessSelector({ maxVisibleAvatars={1} avatarSize={listOnly ? 30 : 25} tooltipOptions={{disabled: !userCanEditDashboardPermissions}} + renderCollapsedAvatars={renderCollapsedAvatars} /> ); @@ -320,7 +373,9 @@ function EditAccessSelector({ return ( {dropdownMenu} @@ -381,3 +436,20 @@ const FilterButtons = styled(ButtonBar)` margin-bottom: ${space(0.5)}; justify-content: flex-end; `; + +const CollapsedAvatarTooltip = styled('div')` + max-height: 200px; + overflow-y: auto; +`; + +const CollapsedAvatarTooltipListItem = styled('div')` + display: flex; + align-items: center; + gap: ${space(1)}; +`; + +const Plus = styled('span')` + font-size: 10px; + margin-left: 1px; + margin-right: -1px; +`; diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/index.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/index.tsx index 8f8dbdbf307aae..25451e9d88240a 100644 --- a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/index.tsx +++ b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/index.tsx @@ -224,7 +224,7 @@ export function FilterResultsStep({ ); } -function WidgetOnDemandQueryWarning(props: { +export function WidgetOnDemandQueryWarning(props: { query: WidgetQuery; queryIndex: number; validatedWidgetResponse: Props['validatedWidgetResponse']; diff --git a/static/app/views/dashboards/widgetBuilder/components/groupBySelector.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/groupBySelector.spec.tsx index a50e490180e3ba..768d3007484c40 100644 --- a/static/app/views/dashboards/widgetBuilder/components/groupBySelector.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/groupBySelector.spec.tsx @@ -27,7 +27,7 @@ describe('WidgetBuilderGroupBySelector', function () { render( - + ); @@ -41,7 +41,7 @@ describe('WidgetBuilderGroupBySelector', function () { render( - + ); diff --git a/static/app/views/dashboards/widgetBuilder/components/groupBySelector.tsx b/static/app/views/dashboards/widgetBuilder/components/groupBySelector.tsx index 2f13473c71152d..14bdb570389546 100644 --- a/static/app/views/dashboards/widgetBuilder/components/groupBySelector.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/groupBySelector.tsx @@ -3,19 +3,25 @@ import {Fragment} from 'react'; import {t} from 'sentry/locale'; import type {TagCollection} from 'sentry/types/group'; import type {QueryFieldValue} from 'sentry/utils/discover/fields'; +import type {UseApiQueryResult} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; import useOrganization from 'sentry/utils/useOrganization'; import useTags from 'sentry/utils/useTags'; import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base'; -import {useValidateWidgetQuery} from 'sentry/views/dashboards/hooks/useValidateWidget'; -import {WidgetType} from 'sentry/views/dashboards/types'; +import {type ValidateWidgetResponse, WidgetType} from 'sentry/views/dashboards/types'; import {GroupBySelector} from 'sentry/views/dashboards/widgetBuilder/buildSteps/groupByStep/groupBySelector'; import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader'; import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState'; -import {convertBuilderStateToWidget} from 'sentry/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget'; import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext'; -function WidgetBuilderGroupBySelector() { +interface WidgetBuilderGroupBySelectorProps { + validatedWidgetResponse: UseApiQueryResult; +} + +function WidgetBuilderGroupBySelector({ + validatedWidgetResponse, +}: WidgetBuilderGroupBySelectorProps) { const {state, dispatch} = useWidgetBuilderContext(); const organization = useOrganization(); @@ -27,10 +33,6 @@ function WidgetBuilderGroupBySelector() { tags = {...numericSpanTags, ...stringSpanTags}; } - const widget = convertBuilderStateToWidget(state); - - const validatedWidgetResponse = useValidateWidgetQuery(widget); - const datasetConfig = getDatasetConfig(state.dataset); const groupByOptions = datasetConfig.getGroupByFieldOptions ? datasetConfig.getGroupByFieldOptions(organization, tags) diff --git a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx index b776dd96b9b9d0..139667ce013187 100644 --- a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx @@ -16,8 +16,6 @@ import EventView from 'sentry/utils/discover/eventView'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality'; import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; -import {decodeBoolean} from 'sentry/utils/queryString'; -import useLocationQuery from 'sentry/utils/url/useLocationQuery'; import useKeyPress from 'sentry/utils/useKeyPress'; import {useLocation} from 'sentry/utils/useLocation'; import useMedia from 'sentry/utils/useMedia'; @@ -28,7 +26,6 @@ import { type DashboardFilters, DisplayType, type Widget, - WidgetType, } from 'sentry/views/dashboards/types'; import { DEFAULT_WIDGET_DRAG_POSITIONING, @@ -225,11 +222,6 @@ export function WidgetPreviewContainer({ const organization = useOrganization(); const location = useLocation(); const theme = useTheme(); - const {useRpc} = useLocationQuery({ - fields: { - useRpc: decodeBoolean, - }, - }); const isSmallScreen = useMedia(`(max-width: ${theme.breakpoints.small})`); // if small screen and draggable, enable dragging const isDragEnabled = isSmallScreen && isDraggable; @@ -335,10 +327,6 @@ export function WidgetPreviewContainer({ ) : ( { it('renders a dataset-specific query filter bar', async () => { render( - {}} /> + {}} + validatedWidgetResponse={{} as any} + /> , { organization, @@ -54,7 +57,10 @@ describe('QueryFilterBuilder', () => { render( - {}} /> + {}} + validatedWidgetResponse={{} as any} + /> , { organization, @@ -77,7 +83,10 @@ describe('QueryFilterBuilder', () => { it('renders a legend alias input for charts', async () => { render( - {}} /> + {}} + validatedWidgetResponse={{} as any} + /> , { organization, @@ -99,7 +108,10 @@ describe('QueryFilterBuilder', () => { it('limits number of filter queries to 3', async () => { render( - {}} /> + {}} + validatedWidgetResponse={{} as any} + /> , { organization, diff --git a/static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.tsx b/static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.tsx index e98b16d8fad064..3610b8c91e81ff 100644 --- a/static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.tsx @@ -11,10 +11,17 @@ import { createOnDemandFilterWarning, shouldDisplayOnDemandWidgetWarning, } from 'sentry/utils/onDemandMetrics'; +import type {UseApiQueryResult} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base'; -import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; +import { + DisplayType, + type ValidateWidgetResponse, + WidgetType, +} from 'sentry/views/dashboards/types'; +import {WidgetOnDemandQueryWarning} from 'sentry/views/dashboards/widgetBuilder/buildSteps/filterResultsStep'; import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader'; import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState'; @@ -23,15 +30,16 @@ import {convertBuilderStateToWidget} from 'sentry/views/dashboards/widgetBuilder interface WidgetBuilderQueryFilterBuilderProps { onQueryConditionChange: (valid: boolean) => void; + validatedWidgetResponse: UseApiQueryResult; } function WidgetBuilderQueryFilterBuilder({ onQueryConditionChange, + validatedWidgetResponse, }: WidgetBuilderQueryFilterBuilderProps) { const {state, dispatch} = useWidgetBuilderContext(); const {selection} = usePageFilters(); const organization = useOrganization(); - const [queryConditionValidity, setQueryConditionValidity] = useState(() => { // Make a validity entry for each query condition initially return state.query?.map(() => true) ?? []; @@ -168,6 +176,17 @@ function WidgetBuilderQueryFilterBuilder({ }} /> )} + {shouldDisplayOnDemandWidgetWarning( + widget.queries[index]!, + widgetType, + organization + ) && ( + + )} {state.query && state.query?.length > 1 && ( )} diff --git a/static/app/views/dashboards/widgetBuilder/components/rpcToggle.tsx b/static/app/views/dashboards/widgetBuilder/components/rpcToggle.tsx deleted file mode 100644 index 72de67744f9cb0..00000000000000 --- a/static/app/views/dashboards/widgetBuilder/components/rpcToggle.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import {Flex} from 'sentry/components/container/flex'; -import SwitchButton from 'sentry/components/switchButton'; -import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; -import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; -import {useQueryParamState} from 'sentry/views/dashboards/widgetBuilder/hooks/useQueryParamState'; - -export const DASHBOARD_RPC_TOGGLE_KEY = 'dashboards-spans-use-rpc'; - -function RPCToggle() { - const [_, setIsRpcEnabled] = useQueryParamState({ - fieldName: 'useRpc', - }); - // This is hacky, but we need to access the RPC toggle state in the spans dataset config - // and I don't want to pass it down as a prop when it's only temporary. - const [isRpcEnabled, setRpcLocalStorage] = useLocalStorageState( - DASHBOARD_RPC_TOGGLE_KEY, - false - ); - - return ( - - { - const newValue = !isRpcEnabled; - setIsRpcEnabled(newValue); - setRpcLocalStorage(newValue); - }} - /> -
{t('Use RPC')}
-
- ); -} - -export default RPCToggle; diff --git a/static/app/views/dashboards/widgetBuilder/components/saveButton.tsx b/static/app/views/dashboards/widgetBuilder/components/saveButton.tsx index 1dbcff95cc795a..a63f5158a2707d 100644 --- a/static/app/views/dashboards/widgetBuilder/components/saveButton.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/saveButton.tsx @@ -1,7 +1,11 @@ -import {useCallback} from 'react'; +import {useCallback, useState} from 'react'; import {validateWidget} from 'sentry/actionCreators/dashboards'; -import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import { + addErrorMessage, + addLoadingMessage, + clearIndicators, +} from 'sentry/actionCreators/indicator'; import {Button} from 'sentry/components/button'; import {t} from 'sentry/locale'; import useApi from 'sentry/utils/useApi'; @@ -22,13 +26,18 @@ function SaveButton({isEditing, onSave, setError}: SaveButtonProps) { const {widgetIndex} = useParams(); const api = useApi(); const organization = useOrganization(); + const [isSaving, setIsSaving] = useState(false); const handleSave = useCallback(async () => { const widget = convertBuilderStateToWidget(state); + setIsSaving(true); try { await validateWidget(api, organization.slug, widget); + addLoadingMessage(t('Saving widget')); onSave({index: Number(widgetIndex), widget}); } catch (error) { + setIsSaving(false); + clearIndicators(); const errorDetails = error.responseJSON || error; setError(errorDetails); addErrorMessage(t('Unable to save widget')); @@ -36,7 +45,7 @@ function SaveButton({isEditing, onSave, setError}: SaveButtonProps) { }, [api, onSave, organization.slug, state, widgetIndex, setError]); return ( - ); diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize.spec.tsx index ab3c733e7b0d6f..aa3c86927c565e 100644 --- a/static/app/views/dashboards/widgetBuilder/components/visualize.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/visualize.spec.tsx @@ -97,7 +97,7 @@ describe('Visualize', () => { expect(screen.queryAllByRole('button', {name: 'Remove field'})[0]).toBeDisabled(); }); - it('disables the column selection when the aggregate has no parameters', async () => { + it('removes the column selection when the aggregate has no parameters', async () => { render( @@ -120,10 +120,10 @@ describe('Visualize', () => { await userEvent.click(screen.getByRole('button', {name: 'Aggregate Selection'})); await userEvent.click(screen.getByRole('option', {name: 'count'})); - expect(screen.getByRole('button', {name: 'Column Selection'})).toBeDisabled(); + expect( + screen.queryByRole('button', {name: 'Column Selection'}) + ).not.toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Aggregate Selection'})).toBeEnabled(); - - expect(screen.getByRole('button', {name: 'Column Selection'})).toHaveValue(''); }); it('adds the default value for the column selection when the aggregate has parameters', async () => { @@ -145,8 +145,6 @@ describe('Visualize', () => { } ); - expect(screen.getByRole('button', {name: 'Column Selection'})).toBeDisabled(); - await userEvent.click(screen.getByRole('button', {name: 'Aggregate Selection'})); await userEvent.click(screen.getByRole('option', {name: 'p95'})); @@ -390,9 +388,9 @@ describe('Visualize', () => { await userEvent.click(screen.getByRole('button', {name: 'Aggregate Selection'})); await userEvent.click(screen.getByRole('option', {name: 'count'})); - expect(screen.getByRole('button', {name: 'Column Selection'})).toHaveTextContent( - 'None' - ); + expect( + screen.queryByRole('button', {name: 'Column Selection'}) + ).not.toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Aggregate Selection'})).toHaveTextContent( 'count' ); @@ -823,9 +821,9 @@ describe('Visualize', () => { await userEvent.click(screen.getByRole('button', {name: 'Aggregate Selection'})); await userEvent.click(screen.getByRole('option', {name: 'count'})); - expect(screen.getByRole('button', {name: 'Column Selection'})).toHaveTextContent( - 'None' - ); + expect( + screen.queryByRole('button', {name: 'Column Selection'}) + ).not.toBeInTheDocument(); }); it('uses the provided value for a value parameter field', async () => { diff --git a/static/app/views/dashboards/widgetBuilder/components/visualize.tsx b/static/app/views/dashboards/widgetBuilder/components/visualize.tsx index 4a72a170429a98..cd6e96862a142e 100644 --- a/static/app/views/dashboards/widgetBuilder/components/visualize.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/visualize.tsx @@ -366,6 +366,17 @@ function Visualize({error, setError}: VisualizeProps) { ? matchingAggregate?.value.meta.parameters.slice(1) : []; + // Apdex and User Misery are special cases where the column parameter is not applicable + const isApdexOrUserMisery = + matchingAggregate?.value.meta.name === 'apdex' || + matchingAggregate?.value.meta.name === 'user_misery'; + + const hasColumnParameter = + (fields[index]!.kind === FieldValueKind.FUNCTION && + !isApdexOrUserMisery && + matchingAggregate?.value.meta.parameters.length !== 0) || + fields[index]!.kind === FieldValueKind.FIELD; + return ( {fields.length > 1 && state.displayType === DisplayType.BIG_NUMBER && ( @@ -410,47 +421,46 @@ function Visualize({error, setError}: VisualizeProps) { /> ) : ( - + {/** TODO: Add support for the value parameter type for cases like user_misery, apdex */} - { - const newFields = cloneDeep(fields); - const currentField = newFields[index]!; - // Update the current field's aggregate with the new aggregate - if (currentField.kind === FieldValueKind.FUNCTION) { - currentField.function[1] = newField.value as string; + {hasColumnParameter && ( + + onChange={newField => { + const newFields = cloneDeep(fields); + const currentField = newFields[index]!; + // Update the current field's aggregate with the new aggregate + if (currentField.kind === FieldValueKind.FUNCTION) { + currentField.function[1] = newField.value as string; + } + if (currentField.kind === FieldValueKind.FIELD) { + currentField.field = newField.value as string; + } + dispatch({ + type: updateAction, + payload: newFields, + }); + setError?.({...error, queries: []}); + }} + triggerProps={{ + 'aria-label': t('Column Selection'), + }} + /> + )} ` + ${p => + p.hasColumnParameter + ? ` + width: fit-content; + max-width: 150px; + left: -1px; + ` + : ` + width: 100%; + `} > button { width: 100%; @@ -862,7 +879,7 @@ const FieldBar = styled('div')` flex: 3; `; -const PrimarySelectRow = styled('div')` +const PrimarySelectRow = styled('div')<{hasColumnParameter: boolean}>` display: flex; width: 100%; flex: 3; @@ -873,8 +890,12 @@ const PrimarySelectRow = styled('div')` } & > ${AggregateCompactSelect} > button { - border-top-left-radius: 0; - border-bottom-left-radius: 0; + ${p => + p.hasColumnParameter && + ` + border-top-left-radius: 0; + border-bottom-left-radius: 0; + `} } `; diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.spec.tsx index 18ad6a8ff6b17b..fe88e5925fa2d0 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.spec.tsx @@ -11,7 +11,7 @@ import { waitFor, } from 'sentry-test/reactTestingLibrary'; -import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {addErrorMessage, addLoadingMessage} from 'sentry/actionCreators/indicator'; import ModalStore from 'sentry/stores/modalStore'; import useCustomMeasurements from 'sentry/utils/useCustomMeasurements'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; @@ -265,6 +265,52 @@ describe('WidgetBuilderSlideout', () => { expect(screen.getByText('Create Custom Widget')).toBeInTheDocument(); }); + it('should save the widget from the widget builder with loading messages if the widget is valid', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/dashboards/widgets/', + method: 'POST', + body: {}, + statusCode: 200, + }); + + render( + + + , + { + organization, + router: RouterFixture({ + location: LocationFixture({ + query: { + field: [], + yAxis: ['count()'], + dataset: WidgetType.TRANSACTIONS, + displayType: DisplayType.LINE, + title: 'Widget Title', + }, + }), + }), + } + ); + + await userEvent.click(await screen.findByText('Add Widget')); + + await waitFor(() => { + expect(addLoadingMessage).toHaveBeenCalledWith('Saving widget'); + }); + }); + it('clears the alias when dataset changes', async () => { render( diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx index d50f0c64d7620a..4b974346e4169d 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx @@ -11,14 +11,13 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery'; import useMedia from 'sentry/utils/useMedia'; -import useOrganization from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; +import {useValidateWidgetQuery} from 'sentry/views/dashboards/hooks/useValidateWidget'; import { type DashboardDetails, type DashboardFilters, DisplayType, type Widget, - WidgetType, } from 'sentry/views/dashboards/types'; import WidgetBuilderDatasetSelector from 'sentry/views/dashboards/widgetBuilder/components/datasetSelector'; import WidgetBuilderFilterBar from 'sentry/views/dashboards/widgetBuilder/components/filtersBar'; @@ -29,7 +28,6 @@ import { WidgetPreviewContainer, } from 'sentry/views/dashboards/widgetBuilder/components/newWidgetBuilder'; import WidgetBuilderQueryFilterBuilder from 'sentry/views/dashboards/widgetBuilder/components/queryFilterBuilder'; -import RPCToggle from 'sentry/views/dashboards/widgetBuilder/components/rpcToggle'; import SaveButton from 'sentry/views/dashboards/widgetBuilder/components/saveButton'; import WidgetBuilderSortBySelector from 'sentry/views/dashboards/widgetBuilder/components/sortBySelector'; import ThresholdsSection from 'sentry/views/dashboards/widgetBuilder/components/thresholds'; @@ -37,6 +35,7 @@ import WidgetBuilderTypeSelector from 'sentry/views/dashboards/widgetBuilder/com import Visualize from 'sentry/views/dashboards/widgetBuilder/components/visualize'; import WidgetTemplatesList from 'sentry/views/dashboards/widgetBuilder/components/widgetTemplatesList'; import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; +import {convertBuilderStateToWidget} from 'sentry/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget'; type WidgetBuilderSlideoutProps = { dashboard: DashboardDetails; @@ -67,13 +66,16 @@ function WidgetBuilderSlideout({ onDataFetched, thresholdMetaState, }: WidgetBuilderSlideoutProps) { - const organization = useOrganization(); const {state} = useWidgetBuilderContext(); const [initialState] = useState(state); const [error, setError] = useState>({}); const {widgetIndex} = useParams(); const theme = useTheme(); + const validatedWidgetResponse = useValidateWidgetQuery( + convertBuilderStateToWidget(state) + ); + const isEditing = widgetIndex !== undefined; const title = openWidgetTemplates ? t('Add from Widget Library') @@ -148,12 +150,6 @@ function WidgetBuilderSlideout({
- {organization.features.includes('visibility-explore-dataset') && - state.dataset === WidgetType.SPANS && ( -
- -
- )}
@@ -176,6 +172,7 @@ function WidgetBuilderSlideout({
{state.displayType === DisplayType.BIG_NUMBER && ( @@ -188,7 +185,9 @@ function WidgetBuilderSlideout({ )} {isChartWidget && (
- +
)} {showSortByStep && ( diff --git a/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.spec.tsx b/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.spec.tsx index 17a39b19e23790..2c0d4c7b772543 100644 --- a/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.spec.tsx @@ -1107,6 +1107,36 @@ describe('useWidgetBuilderState', () => { expect(result.current.state.sort).toEqual([{field: 'notInFields', kind: 'desc'}]); }); + + it('adds a default sort when adding a grouping for a timeseries chart', () => { + mockedUsedLocation.mockReturnValue( + LocationFixture({ + query: { + displayType: DisplayType.LINE, + field: [], + yAxis: ['count()'], + }, + }) + ); + + const {result} = renderHook(() => useWidgetBuilderState(), { + wrapper: WidgetBuilderProvider, + }); + + expect(result.current.state.yAxis).toEqual([ + {function: ['count', '', undefined, undefined], kind: 'function'}, + ]); + + act(() => { + result.current.dispatch({ + type: BuilderStateAction.SET_FIELDS, + payload: [{field: 'browser.name', kind: FieldValueKind.FIELD}], + }); + }); + + // The y-axis takes priority + expect(result.current.state.sort).toEqual([{field: 'count()', kind: 'desc'}]); + }); }); describe('yAxis', () => { @@ -1146,6 +1176,36 @@ describe('useWidgetBuilderState', () => { }, ]); }); + + it('clears the sort when the y-axis changes and there is no grouping', () => { + mockedUsedLocation.mockReturnValue( + LocationFixture({ + query: { + displayType: DisplayType.LINE, + field: [], + yAxis: ['count()'], + sort: ['-count()'], + }, + }) + ); + + const {result} = renderHook(() => useWidgetBuilderState(), { + wrapper: WidgetBuilderProvider, + }); + + expect(result.current.state.sort).toEqual([{field: 'count()', kind: 'desc'}]); + + act(() => { + result.current.dispatch({ + type: BuilderStateAction.SET_Y_AXIS, + payload: [ + {function: ['count_unique', 'user', undefined, undefined], kind: 'function'}, + ], + }); + }); + + expect(result.current.state.sort).toEqual([]); + }); }); describe('sort', () => { diff --git a/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx b/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx index 4976a8ff4f2117..9205dc8ad13800 100644 --- a/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx +++ b/static/app/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState.tsx @@ -337,9 +337,30 @@ function useWidgetBuilderState(): { } } } + + if ( + displayType !== DisplayType.TABLE && + displayType !== DisplayType.BIG_NUMBER && + action.payload.length > 0 + ) { + // Adding a grouping, so default the sort to the first aggregate if possible + setSort([ + { + kind: 'desc', + field: generateFieldAsString( + (yAxis?.[0] as QueryFieldValue) ?? + (action.payload[0] as QueryFieldValue) + ), + }, + ]); + } break; case BuilderStateAction.SET_Y_AXIS: setYAxis(action.payload); + if (action.payload.length > 0 && fields?.length === 0) { + // Clear the sort if there is no grouping + setSort([]); + } break; case BuilderStateAction.SET_QUERY: setQuery(action.payload); diff --git a/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.spec.tsx b/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.spec.tsx index 65ec9ba7f26430..ee49be73e5c9c0 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/convertWidgetToBuilderStateParams.spec.tsx @@ -4,7 +4,11 @@ import {getDefaultWidget} from 'sentry/views/dashboards/widgetBuilder/utils/getD describe('convertWidgetToBuilderStateParams', () => { it('should not pass along yAxis when converting a table to builder params', () => { - const widget = {...getDefaultWidget(WidgetType.ERRORS), aggregates: ['count()']}; + const widget = { + ...getDefaultWidget(WidgetType.ERRORS), + displayType: DisplayType.TABLE, + aggregates: ['count()'], + }; const params = convertWidgetToBuilderStateParams(widget); expect(params.yAxis).toEqual([]); }); @@ -12,6 +16,7 @@ describe('convertWidgetToBuilderStateParams', () => { it('stringifies the fields when converting a table to builder params', () => { const widget = { ...getDefaultWidget(WidgetType.ERRORS), + displayType: DisplayType.TABLE, queries: [ { aggregates: [], diff --git a/static/app/views/dashboards/widgetBuilder/utils/getDefaultWidget.spec.tsx b/static/app/views/dashboards/widgetBuilder/utils/getDefaultWidget.spec.tsx index 2dc0f335cfb38e..711527ef8aa778 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/getDefaultWidget.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/getDefaultWidget.spec.tsx @@ -6,7 +6,7 @@ describe('getDefaultWidget', () => { it('should return a default widget for errors', () => { const widget = getDefaultWidget(WidgetType.ERRORS); expect(widget).toEqual({ - displayType: DisplayType.TABLE, + displayType: DisplayType.LINE, interval: '', title: 'Custom Widget', widgetType: WidgetType.ERRORS, @@ -27,7 +27,7 @@ describe('getDefaultWidget', () => { it('should return a default widget for spans', () => { const widget = getDefaultWidget(WidgetType.SPANS); expect(widget).toEqual({ - displayType: DisplayType.TABLE, + displayType: DisplayType.LINE, interval: '', title: 'Custom Widget', widgetType: WidgetType.SPANS, @@ -69,7 +69,7 @@ describe('getDefaultWidget', () => { it('should return a default widget for releases', () => { const widget = getDefaultWidget(WidgetType.RELEASE); expect(widget).toEqual({ - displayType: DisplayType.TABLE, + displayType: DisplayType.LINE, interval: '', title: 'Custom Widget', widgetType: WidgetType.RELEASE, diff --git a/static/app/views/dashboards/widgetBuilder/utils/getDefaultWidget.tsx b/static/app/views/dashboards/widgetBuilder/utils/getDefaultWidget.tsx index b221822f7e692c..0349719ba8fb7d 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/getDefaultWidget.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/getDefaultWidget.tsx @@ -1,10 +1,10 @@ import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base'; -import {DisplayType, type Widget, type WidgetType} from 'sentry/views/dashboards/types'; +import {DisplayType, type Widget, WidgetType} from 'sentry/views/dashboards/types'; export function getDefaultWidget(widgetType: WidgetType): Widget { const config = getDatasetConfig(widgetType); return { - displayType: DisplayType.TABLE, + displayType: widgetType === WidgetType.ISSUE ? DisplayType.TABLE : DisplayType.LINE, interval: '', title: 'Custom Widget', widgetType, diff --git a/static/app/views/dashboards/widgetCard/chart.tsx b/static/app/views/dashboards/widgetCard/chart.tsx index 0cf22d01268724..25842c58d9a907 100644 --- a/static/app/views/dashboards/widgetCard/chart.tsx +++ b/static/app/views/dashboards/widgetCard/chart.tsx @@ -131,20 +131,8 @@ class WidgetCardChart extends Component { return !isEqual(currentProps, nextProps); } - tableResultComponent({ - loading, - errorMessage, - tableResults, - }: TableResultProps): React.ReactNode { + tableResultComponent({loading, tableResults}: TableResultProps): React.ReactNode { const {location, widget, selection} = this.props; - if (errorMessage) { - return ( - - - - ); - } - if (typeof tableResults === 'undefined') { // Align height to other charts. return ; @@ -178,19 +166,7 @@ class WidgetCardChart extends Component { }); } - bigNumberComponent({ - loading, - errorMessage, - tableResults, - }: TableResultProps): React.ReactNode { - if (errorMessage) { - return ( - - - - ); - } - + bigNumberComponent({loading, tableResults}: TableResultProps): React.ReactNode { if (typeof tableResults === 'undefined' || loading) { return {'\u2014'}; } @@ -291,12 +267,20 @@ class WidgetCardChart extends Component { showConfidenceWarning, } = this.props; + if (errorMessage) { + return ( + + + + ); + } + if (widget.displayType === 'table') { return getDynamicText({ value: ( - {this.tableResultComponent({tableResults, loading, errorMessage})} + {this.tableResultComponent({tableResults, loading})} ), fixed: , @@ -308,24 +292,49 @@ class WidgetCardChart extends Component { - {this.bigNumberComponent({tableResults, loading, errorMessage})} + {this.bigNumberComponent({tableResults, loading})} ); } - - if (errorMessage) { - return ( - - - - ); - } - const {location, selection, onLegendSelectChanged, widgetLegendState} = this.props; const {start, end, period, utc} = selection.datetime; const {projects, environments} = selection; + const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`); + const shouldColorOther = timeseriesResults?.some(({seriesName}) => + seriesName?.match(otherRegex) + ); + const colors = timeseriesResults + ? (theme.charts + .getColorPalette(timeseriesResults.length - (shouldColorOther ? 3 : 2)) + ?.slice() as string[]) + : []; + // TODO(wmak): Need to change this when updating dashboards to support variable topEvents + if (shouldColorOther) { + colors[colors.length] = theme.chartOther; + } + + // Create a list of series based on the order of the fields, + const series = timeseriesResults + ? timeseriesResults + .map((values, i: number) => { + let seriesName = ''; + if (values.seriesName !== undefined) { + seriesName = isEquation(values.seriesName) + ? getEquation(values.seriesName) + : values.seriesName; + } + return { + ...values, + seriesName, + fieldName: seriesName, + color: colors[i], + }; + }) + .filter(Boolean) // NOTE: `timeseriesResults` is a sparse array! We have to filter out the empty slots after the colors are assigned, since the colors are assigned based on sparse array index + : []; + const legend = { left: 0, top: 0, @@ -461,53 +470,12 @@ class WidgetCardChart extends Component { }, }; + const forwardedRef = this.props.chartGroup ? this.handleRef : undefined; + return ( {zoomRenderProps => { - if (errorMessage) { - return ( - - - - ); - } - - const otherRegex = new RegExp(`(?:.* : ${OTHER}$)|^${OTHER}$`); - const shouldColorOther = timeseriesResults?.some(({seriesName}) => - seriesName?.match(otherRegex) - ); - const colors = timeseriesResults - ? (theme.charts - .getColorPalette(timeseriesResults.length - (shouldColorOther ? 3 : 2)) - ?.slice() as string[]) - : []; - // TODO(wmak): Need to change this when updating dashboards to support variable topEvents - if (shouldColorOther) { - colors[colors.length] = theme.chartOther; - } - - // Create a list of series based on the order of the fields, - const series = timeseriesResults - ? timeseriesResults - .map((values, i: number) => { - let seriesName = ''; - if (values.seriesName !== undefined) { - seriesName = isEquation(values.seriesName) - ? getEquation(values.seriesName) - : values.seriesName; - } - return { - ...values, - seriesName, - color: colors[i], - }; - }) - .filter(Boolean) // NOTE: `timeseriesResults` is a sparse array! We have to filter out the empty slots after the colors are assigned, since the colors are assigned based on sparse array index - : []; - - const forwardedRef = this.props.chartGroup ? this.handleRef : undefined; - - return widgetLegendState.widgetRequiresLegendUnselection(widget) ? ( + return ( { environments={environments} projects={projects} memoized + enabled={widgetLegendState.widgetRequiresLegendUnselection(widget)} > {({releaseSeries}) => { // make series name into seriesName:widgetId form for individual widget legend control @@ -559,33 +528,6 @@ class WidgetCardChart extends Component { ); }} - ) : ( - - - - - {getDynamicText({ - value: this.chartComponent({ - ...zoomRenderProps, - ...chartOptions, - // Override default datazoom behaviour for updating Global Selection Header - ...(onZoom ? {onDataZoom: onZoom} : {}), - legend, - series, - onLegendSelectChanged, - forwardedRef, - }), - fixed: , - })} - - {showConfidenceWarning && confidence && ( - - )} - - ); }} diff --git a/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx b/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx index 8ce6756a5bff84..cace54701c4649 100644 --- a/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx +++ b/static/app/views/dashboards/widgetCard/genericWidgetQueries.tsx @@ -34,6 +34,7 @@ function getReferrer(displayType: DisplayType) { } export type OnDataFetchedProps = { + confidence?: Confidence; pageLinks?: string; tableResults?: TableDataWithTitle[]; timeseriesResults?: Series[]; diff --git a/static/app/views/dashboards/widgetCard/index.tsx b/static/app/views/dashboards/widgetCard/index.tsx index 7bace56a76e652..658fa8691ede62 100644 --- a/static/app/views/dashboards/widgetCard/index.tsx +++ b/static/app/views/dashboards/widgetCard/index.tsx @@ -14,7 +14,7 @@ import {space} from 'sentry/styles/space'; import type {PageFilters} from 'sentry/types/core'; import type {Series} from 'sentry/types/echarts'; import type {WithRouterProps} from 'sentry/types/legacyReactRouter'; -import type {Organization} from 'sentry/types/organization'; +import type {Confidence, Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import {getFormattedDate} from 'sentry/utils/dates'; import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery'; @@ -31,7 +31,7 @@ import withSentryRouter from 'sentry/utils/withSentryRouter'; import {DASHBOARD_CHART_GROUP} from 'sentry/views/dashboards/dashboard'; import {useDiscoverSplitAlert} from 'sentry/views/dashboards/discoverSplitAlert'; import {MetricWidgetCard} from 'sentry/views/dashboards/metrics/widgetCard'; -import {WidgetCardChartContainer} from 'sentry/views/dashboards/widgetCard/widgetCardChartContainer'; +import WidgetCardChartContainer from 'sentry/views/dashboards/widgetCard/widgetCardChartContainer'; import type {DashboardFilters, Widget} from '../types'; import {DisplayType, OnDemandExtractionState, WidgetType} from '../types'; @@ -94,6 +94,7 @@ type Props = WithRouterProps & { }; type Data = { + confidence?: Confidence; pageLinks?: string; tableResults?: TableDataWithTitle[]; timeseriesResults?: Series[]; @@ -110,7 +111,7 @@ function WidgetCard(props: Props) { props.onDataFetched(newData.tableResults); } - setData(newData); + setData(prevData => ({...prevData, ...newData})); }; const { @@ -186,6 +187,7 @@ function WidgetCard(props: Props) { tableData: data?.tableResults, seriesResultsType: data?.timeseriesResultsTypes, totalIssuesCount: data?.totalIssuesCount, + confidence: data?.confidence, }); props.router.push({ diff --git a/static/app/views/dashboards/widgetCard/spansWidgetQueries.tsx b/static/app/views/dashboards/widgetCard/spansWidgetQueries.tsx index 082a0f04be0519..9bd979b04b8f4b 100644 --- a/static/app/views/dashboards/widgetCard/spansWidgetQueries.tsx +++ b/static/app/views/dashboards/widgetCard/spansWidgetQueries.tsx @@ -57,16 +57,19 @@ function SpansWidgetQueries({ const [confidence, setConfidence] = useState(null); const afterFetchSeriesData = (result: SeriesResult) => { + let seriesConfidence; if (isMultiSeriesStats(result)) { const dedupedYAxes = dedupeArray(widget.queries[0]?.aggregates ?? []); const seriesMap = transformToSeriesMap(result, dedupedYAxes); const series = dedupedYAxes.flatMap(yAxis => seriesMap[yAxis]).filter(defined); - const seriesConfidence = combineConfidenceForSeries(series); - setConfidence(seriesConfidence); + seriesConfidence = combineConfidenceForSeries(series); } else { - const seriesConfidence = determineSeriesConfidence(result); - setConfidence(seriesConfidence); + seriesConfidence = determineSeriesConfidence(result); } + setConfidence(seriesConfidence); + onDataFetched?.({ + confidence: seriesConfidence, + }); }; return getDynamicText({ diff --git a/static/app/views/dashboards/widgetCard/widgetCardDataLoader.tsx b/static/app/views/dashboards/widgetCard/widgetCardDataLoader.tsx index 30e0236eeb34d3..6675fa516f1c9a 100644 --- a/static/app/views/dashboards/widgetCard/widgetCardDataLoader.tsx +++ b/static/app/views/dashboards/widgetCard/widgetCardDataLoader.tsx @@ -40,6 +40,7 @@ type Props = { | 'timeseriesResults' | 'timeseriesResultsTypes' | 'totalIssuesCount' + | 'confidence' > ) => void; onWidgetSplitDecision?: (splitDecision: WidgetType) => void; diff --git a/static/app/views/dashboards/widgetViewer/widgetViewerContext.tsx b/static/app/views/dashboards/widgetViewer/widgetViewerContext.tsx index 4907bef15ddbad..2a2ab8ff23fb17 100644 --- a/static/app/views/dashboards/widgetViewer/widgetViewerContext.tsx +++ b/static/app/views/dashboards/widgetViewer/widgetViewerContext.tsx @@ -1,17 +1,20 @@ import {createContext} from 'react'; import type {Series} from 'sentry/types/echarts'; +import type {Confidence} from 'sentry/types/organization'; import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery'; import type {AggregationOutputType} from 'sentry/utils/discover/fields'; export type WidgetViewerContextProps = { setData: (data: { + confidence?: Confidence; pageLinks?: string; seriesData?: Series[]; seriesResultsType?: Record; tableData?: TableDataWithTitle[]; totalIssuesCount?: string; }) => void; + confidence?: Confidence; pageLinks?: string; seriesData?: Series[]; seriesResultsType?: Record; diff --git a/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidget.tsx b/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidget.tsx index 93ec782cdc922c..80b757beac839b 100644 --- a/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidget.tsx +++ b/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidget.tsx @@ -3,11 +3,9 @@ import { type TimeSeriesWidgetProps, } from '../timeSeriesWidget/timeSeriesWidget'; -import {AreaChartWidgetSeries} from './areaChartWidgetSeries'; - export interface AreaChartWidgetProps - extends Omit {} + extends Omit {} export function AreaChartWidget(props: AreaChartWidgetProps) { - return ; + return ; } diff --git a/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidgetVisualization.tsx b/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidgetVisualization.tsx new file mode 100644 index 00000000000000..4f5a84c90a16fe --- /dev/null +++ b/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidgetVisualization.tsx @@ -0,0 +1,11 @@ +import { + TimeSeriesWidgetVisualization, + type TimeSeriesWidgetVisualizationProps, +} from '../timeSeriesWidget/timeSeriesWidgetVisualization'; + +export interface AreaChartWidgetVisualizationProps + extends Omit {} + +export function AreaChartWidgetVisualization(props: AreaChartWidgetVisualizationProps) { + return ; +} diff --git a/static/app/views/dashboards/widgets/barChartWidget/barChartWidget.tsx b/static/app/views/dashboards/widgets/barChartWidget/barChartWidget.tsx index 62a55932d530b2..ca2ecfaa588fcd 100644 --- a/static/app/views/dashboards/widgets/barChartWidget/barChartWidget.tsx +++ b/static/app/views/dashboards/widgets/barChartWidget/barChartWidget.tsx @@ -3,11 +3,9 @@ import { type TimeSeriesWidgetProps, } from '../timeSeriesWidget/timeSeriesWidget'; -import {BarChartWidgetSeries} from './barChartWidgetSeries'; - export interface BarChartWidgetProps - extends Omit {} + extends Omit {} export function BarChartWidget(props: BarChartWidgetProps) { - return ; + return ; } diff --git a/static/app/views/dashboards/widgets/barChartWidget/barChartWidgetVisualization.tsx b/static/app/views/dashboards/widgets/barChartWidget/barChartWidgetVisualization.tsx new file mode 100644 index 00000000000000..cb51076e7d4b8f --- /dev/null +++ b/static/app/views/dashboards/widgets/barChartWidget/barChartWidgetVisualization.tsx @@ -0,0 +1,11 @@ +import { + TimeSeriesWidgetVisualization, + type TimeSeriesWidgetVisualizationProps, +} from '../timeSeriesWidget/timeSeriesWidgetVisualization'; + +export interface BarChartWidgetVisualizationProps + extends Omit {} + +export function BarChartWidgetVisualization(props: BarChartWidgetVisualizationProps) { + return ; +} diff --git a/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidget.tsx b/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidget.tsx index c613861ad58333..2a51dd7739e5bc 100644 --- a/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidget.tsx +++ b/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidget.tsx @@ -14,8 +14,6 @@ import { DEFAULT_FIELD, MISSING_DATA_MESSAGE, NON_FINITE_NUMBER_MESSAGE, - X_GUTTER, - Y_GUTTER, } from '../common/settings'; import type {StateProps} from '../common/types'; @@ -92,6 +90,5 @@ const BigNumberResizeWrapper = styled('div')` const LoadingPlaceholder = styled('span')` color: ${p => p.theme[DEEMPHASIS_COLOR_NAME]}; - padding: ${X_GUTTER} ${Y_GUTTER}; font-size: ${p => p.theme.fontSizeLarge}; `; diff --git a/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization.tsx b/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization.tsx index 9592238156d7e1..0303523909a95c 100644 --- a/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization.tsx @@ -15,8 +15,6 @@ import type { Thresholds, } from 'sentry/views/dashboards/widgets/common/types'; -import {X_GUTTER, Y_GUTTER} from '../common/settings'; - import {ThresholdsIndicator} from './thresholdsIndicator'; export interface BigNumberWidgetVisualizationProps { @@ -143,7 +141,7 @@ function Wrapper({children}: any) { const AutoResizeParent = styled('div')` position: absolute; - inset: ${Y_GUTTER} ${X_GUTTER} ${Y_GUTTER} ${X_GUTTER}; + inset: 0; color: ${p => p.theme.headingColor}; diff --git a/static/app/views/dashboards/widgets/common/errorPanel.tsx b/static/app/views/dashboards/widgets/common/errorPanel.tsx index 60146718d028e1..a5cd491752679b 100644 --- a/static/app/views/dashboards/widgets/common/errorPanel.tsx +++ b/static/app/views/dashboards/widgets/common/errorPanel.tsx @@ -27,7 +27,7 @@ const Panel = styled('div')<{height?: string}>` position: absolute; inset: 0; - padding: ${X_GUTTER} ${Y_GUTTER}; + padding: ${Y_GUTTER} ${X_GUTTER}; display: flex; gap: ${space(1)}; diff --git a/static/app/views/dashboards/widgets/common/widgetFrame.tsx b/static/app/views/dashboards/widgets/common/widgetFrame.tsx index 5f3f56d91c9b42..7601f6f0af7fbb 100644 --- a/static/app/views/dashboards/widgets/common/widgetFrame.tsx +++ b/static/app/views/dashboards/widgets/common/widgetFrame.tsx @@ -283,4 +283,5 @@ const VisualizationWrapper = styled('div')` flex-grow: 1; min-height: 0; position: relative; + padding: 0 ${X_GUTTER} ${Y_GUTTER} ${X_GUTTER}; `; diff --git a/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidget.tsx b/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidget.tsx index 18174722b785e4..f4fa719cccd4f6 100644 --- a/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidget.tsx +++ b/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidget.tsx @@ -3,11 +3,9 @@ import { type TimeSeriesWidgetProps, } from '../timeSeriesWidget/timeSeriesWidget'; -import {LineChartWidgetSeries} from './lineChartWidgetSeries'; - export interface LineChartWidgetProps - extends Omit {} + extends Omit {} export function LineChartWidget(props: LineChartWidgetProps) { - return ; + return ; } diff --git a/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidgetVisualization.tsx b/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidgetVisualization.tsx new file mode 100644 index 00000000000000..3ffd39868513cc --- /dev/null +++ b/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidgetVisualization.tsx @@ -0,0 +1,11 @@ +import { + TimeSeriesWidgetVisualization, + type TimeSeriesWidgetVisualizationProps, +} from '../timeSeriesWidget/timeSeriesWidgetVisualization'; + +export interface LineChartWidgetVisualizationProps + extends Omit {} + +export function LineChartWidgetVisualization(props: LineChartWidgetVisualizationProps) { + return ; +} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx index bb820e99700e77..2e9b486ad29bdd 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx @@ -12,13 +12,15 @@ import { type TimeSeriesWidgetVisualizationProps, } from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; -import {MISSING_DATA_MESSAGE, X_GUTTER, Y_GUTTER} from '../common/settings'; +import {MISSING_DATA_MESSAGE} from '../common/settings'; import type {StateProps} from '../common/types'; export interface TimeSeriesWidgetProps extends StateProps, Omit, - Partial {} + Partial { + visualizationType: TimeSeriesWidgetVisualizationProps['visualizationType']; +} export function TimeSeriesWidget(props: TimeSeriesWidgetProps) { const {timeseries} = props; @@ -55,14 +57,14 @@ export function TimeSeriesWidget(props: TimeSeriesWidgetProps) { error={error} onRetry={props.onRetry} > - {defined(timeseries) && defined(props.SeriesConstructor) && ( + {defined(timeseries) && ( @@ -74,7 +76,6 @@ export function TimeSeriesWidget(props: TimeSeriesWidgetProps) { const TimeSeriesWrapper = styled('div')` flex-grow: 1; - padding: 0 ${X_GUTTER} ${Y_GUTTER} ${X_GUTTER}; `; const LoadingPlaceholder = styled('div')` diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx index 5d7a09f9a829a1..2e69f9302a565d 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx @@ -20,24 +20,31 @@ import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import {useWidgetSyncContext} from '../../contexts/widgetSyncContext'; +import {AreaChartWidgetSeries} from '../areaChartWidget/areaChartWidgetSeries'; +import {BarChartWidgetSeries} from '../barChartWidget/barChartWidgetSeries'; import type { Aliases, Release, TimeseriesData, TimeseriesSelection, } from '../common/types'; +import {LineChartWidgetSeries} from '../lineChartWidget/lineChartWidgetSeries'; import {formatTooltipValue} from './formatTooltipValue'; import {formatYAxisValue} from './formatYAxisValue'; import {ReleaseSeries} from './releaseSeries'; import {splitSeriesIntoCompleteAndIncomplete} from './splitSeriesIntoCompleteAndIncomplete'; +type VisualizationType = 'area' | 'line' | 'bar'; + +type SeriesConstructor = ( + timeserie: TimeseriesData, + complete?: boolean +) => LineSeriesOption | BarSeriesOption; + export interface TimeSeriesWidgetVisualizationProps { - SeriesConstructor: ( - timeserie: TimeseriesData, - complete?: boolean - ) => LineSeriesOption | BarSeriesOption; timeseries: TimeseriesData[]; + visualizationType: VisualizationType; aliases?: Aliases; dataCompletenessDelay?: number; onTimeseriesSelectionChange?: (selection: TimeseriesSelection) => void; @@ -198,6 +205,8 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati const showLegend = visibleSeriesCount > 1; + const SeriesConstructor = SeriesConstructors[props.visualizationType]; + return ( { @@ -210,10 +219,10 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati autoHeightResize series={[ ...completeSeries.map(timeserie => { - return props.SeriesConstructor(timeserie, true); + return SeriesConstructor(timeserie, true); }), ...incompleteSeries.map(timeserie => { - return props.SeriesConstructor(timeserie, false); + return SeriesConstructor(timeserie, false); }), releaseSeries && LineSeries({ @@ -290,3 +299,9 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati } const FALLBACK_TYPE = 'number'; + +const SeriesConstructors: Record = { + area: AreaChartWidgetSeries, + line: LineChartWidgetSeries, + bar: BarChartWidgetSeries, +}; diff --git a/static/app/views/explore/charts/index.tsx b/static/app/views/explore/charts/index.tsx index 06be70b04d08d4..dadd06ee4102ab 100644 --- a/static/app/views/explore/charts/index.tsx +++ b/static/app/views/explore/charts/index.tsx @@ -19,7 +19,6 @@ import {DiscoverDatasets} from 'sentry/utils/discover/types'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import usePageFilters from 'sentry/utils/usePageFilters'; import usePrevious from 'sentry/utils/usePrevious'; -import {formatVersion} from 'sentry/utils/versions/formatVersion'; import {ConfidenceFooter} from 'sentry/views/explore/charts/confidenceFooter'; import ChartContextMenu from 'sentry/views/explore/components/chartContextMenu'; import { @@ -233,7 +232,6 @@ export function ExploreCharts({ top: '32px', // make room to fit the legend above the chart bottom: '0', }} - legendFormatter={value => formatVersion(value)} legendOptions={{ itemGap: 24, top: '4px', diff --git a/static/app/views/explore/components/chartContextMenu.tsx b/static/app/views/explore/components/chartContextMenu.tsx index d5b05cfcd8c2d7..071ced147a11b9 100644 --- a/static/app/views/explore/components/chartContextMenu.tsx +++ b/static/app/views/explore/components/chartContextMenu.tsx @@ -1,3 +1,6 @@ +import styled from '@emotion/styled'; + +import Feature from 'sentry/components/acl/feature'; import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu'; import {IconEllipsis} from 'sentry/icons'; import {t} from 'sentry/locale'; @@ -64,10 +67,21 @@ function ChartContextMenu({ } if (organization.features.includes('dashboards-eap')) { + const disableAddToDashboard = !organization.features.includes('dashboards-edit'); items.push({ key: 'add-to-dashboard', - label: t('Add to Dashboard'), - onAction: () => addToDashboard(visualizeIndex), + textValue: t('Add to Dashboard'), + label: ( + {t('Add to Dashboard')}} + > + {t('Add to Dashboard')} + + ), + disabled: disableAddToDashboard, + onAction: !disableAddToDashboard ? () => addToDashboard(visualizeIndex) : undefined, }); } @@ -90,3 +104,7 @@ function ChartContextMenu({ } export default ChartContextMenu; + +const DisabledText = styled('span')` + color: ${p => p.theme.disabled}; +`; diff --git a/static/app/views/explore/content.tsx b/static/app/views/explore/content.tsx index ac4aaa2d1164c4..c067f6d85c6e41 100644 --- a/static/app/views/explore/content.tsx +++ b/static/app/views/explore/content.tsx @@ -94,12 +94,16 @@ function ExploreContentImpl() { ? 'traces' : 'samples'; + const limit = 25; + const aggregatesTableResult = useExploreAggregatesTable({ query, + limit, enabled: queryType === 'aggregate', }); const spansTableResult = useExploreSpansTable({ query, + limit, enabled: queryType === 'samples', }); const tracesTableResult = useExploreTracesTable({ diff --git a/static/app/views/explore/hooks/useAddToDashboard.spec.tsx b/static/app/views/explore/hooks/useAddToDashboard.spec.tsx index d56659be9973fe..e71b67864195c1 100644 --- a/static/app/views/explore/hooks/useAddToDashboard.spec.tsx +++ b/static/app/views/explore/hooks/useAddToDashboard.spec.tsx @@ -44,7 +44,7 @@ describe('AddToDashboardButton', () => { expect.objectContaining({ // For Add + Stay on Page widget: { - title: 'Custom Explore Widget', + title: 'Custom Widget', displayType: DisplayType.LINE, interval: undefined, limit: undefined, @@ -72,7 +72,7 @@ describe('AddToDashboardButton', () => { 'span.duration', 'timestamp', ], - defaultTitle: 'Custom Explore Widget', + defaultTitle: 'Custom Widget', defaultWidgetQuery: 'name=&aggregates=avg(span.duration)&columns=&fields=avg(span.duration)&conditions=&orderby=-timestamp', displayType: DisplayType.LINE, @@ -117,7 +117,7 @@ describe('AddToDashboardButton', () => { expect.objectContaining({ // For Add + Stay on Page widget: { - title: 'Custom Explore Widget', + title: 'Custom Widget', displayType: DisplayType.LINE, interval: undefined, limit: undefined, @@ -145,7 +145,7 @@ describe('AddToDashboardButton', () => { 'span.duration', 'timestamp', ], - defaultTitle: 'Custom Explore Widget', + defaultTitle: 'Custom Widget', defaultWidgetQuery: 'name=&aggregates=max(span.duration)&columns=&fields=max(span.duration)&conditions=&orderby=-timestamp', displayType: DisplayType.LINE, @@ -178,7 +178,7 @@ describe('AddToDashboardButton', () => { expect.objectContaining({ // For Add + Stay on Page widget: { - title: 'Custom Explore Widget', + title: 'Custom Widget', displayType: DisplayType.LINE, interval: undefined, limit: undefined, @@ -199,7 +199,7 @@ describe('AddToDashboardButton', () => { widgetAsQueryParams: expect.objectContaining({ dataset: WidgetType.SPANS, defaultTableColumns: ['avg(span.duration)'], - defaultTitle: 'Custom Explore Widget', + defaultTitle: 'Custom Widget', defaultWidgetQuery: 'name=&aggregates=avg(span.duration)&columns=&fields=avg(span.duration)&conditions=&orderby=-avg(span.duration)', displayType: DisplayType.LINE, @@ -238,7 +238,7 @@ describe('AddToDashboardButton', () => { expect.objectContaining({ // For Add + Stay on Page widget: { - title: 'Custom Explore Widget', + title: 'Custom Widget', displayType: DisplayType.LINE, interval: undefined, limit: undefined, @@ -267,7 +267,7 @@ describe('AddToDashboardButton', () => { 'max(span.duration)', 'min(span.duration)', ], - defaultTitle: 'Custom Explore Widget', + defaultTitle: 'Custom Widget', defaultWidgetQuery: 'name=&aggregates=avg(span.duration)%2Cmax(span.duration)%2Cmin(span.duration)&columns=&fields=avg(span.duration)%2Cmax(span.duration)%2Cmin(span.duration)&conditions=&orderby=-avg(span.duration)', displayType: DisplayType.LINE, diff --git a/static/app/views/explore/hooks/useAddToDashboard.tsx b/static/app/views/explore/hooks/useAddToDashboard.tsx index 42b94161074f89..9605e470b67e2c 100644 --- a/static/app/views/explore/hooks/useAddToDashboard.tsx +++ b/static/app/views/explore/hooks/useAddToDashboard.tsx @@ -62,7 +62,7 @@ export function useAddToDashboard() { const search = new MutableSearch(query); const discoverQuery: NewQuery = { - name: t('Custom Explore Widget'), + name: t('Custom Widget'), fields, orderby: sortBys.map(formatSort), query: search.formatString(), diff --git a/static/app/views/explore/hooks/useExploreAggregatesTable.tsx b/static/app/views/explore/hooks/useExploreAggregatesTable.tsx index 86a00e42b9a68b..1f83525c09d67a 100644 --- a/static/app/views/explore/hooks/useExploreAggregatesTable.tsx +++ b/static/app/views/explore/hooks/useExploreAggregatesTable.tsx @@ -15,6 +15,7 @@ import {useSpansQuery} from 'sentry/views/insights/common/queries/useSpansQuery' interface UseExploreAggregatesTableOptions { enabled: boolean; + limit: number; query: string; } @@ -26,6 +27,7 @@ export interface AggregatesTableResult { export function useExploreAggregatesTable({ enabled, + limit, query, }: UseExploreAggregatesTableOptions): AggregatesTableResult { const {selection} = usePageFilters(); @@ -84,6 +86,7 @@ export function useExploreAggregatesTable({ enabled, eventView, initialData: [], + limit, referrer: 'api.explore.spans-aggregates-table', }); diff --git a/static/app/views/explore/hooks/useExploreSpansTable.tsx b/static/app/views/explore/hooks/useExploreSpansTable.tsx index ec726e813ca000..5090bab6289eb1 100644 --- a/static/app/views/explore/hooks/useExploreSpansTable.tsx +++ b/static/app/views/explore/hooks/useExploreSpansTable.tsx @@ -13,6 +13,7 @@ import {useSpansQuery} from 'sentry/views/insights/common/queries/useSpansQuery' interface UseExploreSpansTableOptions { enabled: boolean; + limit: number; query: string; } @@ -23,6 +24,7 @@ export interface SpansTableResult { export function useExploreSpansTable({ enabled, + limit, query, }: UseExploreSpansTableOptions): SpansTableResult { const {selection} = usePageFilters(); @@ -70,6 +72,7 @@ export function useExploreSpansTable({ enabled, eventView, initialData: [], + limit, referrer: 'api.explore.spans-samples-table', allowAggregateConditions: false, }); diff --git a/static/app/views/insights/common/components/chart.tsx b/static/app/views/insights/common/components/chart.tsx index 5f8fba858b3b5c..c463974fc1bb9a 100644 --- a/static/app/views/insights/common/components/chart.tsx +++ b/static/app/views/insights/common/components/chart.tsx @@ -143,11 +143,7 @@ function Chart({ onLegendSelectChanged, onDataZoom, legendOptions, - /** - * Setting a default formatter for some reason causes `>` to - * render correctly instead of rendering as `>` in the legend. - */ - legendFormatter = name => name, + legendFormatter, }: Props) { const theme = useTheme(); const pageFilters = usePageFilters(); @@ -355,7 +351,13 @@ function Chart({ }; const legend = isLegendVisible - ? {top: 0, right: 10, formatter: legendFormatter, ...legendOptions} + ? { + top: 0, + right: 10, + truncate: true, + formatter: legendFormatter, + ...legendOptions, + } : undefined; const areaChartProps = { diff --git a/static/app/views/insights/common/components/insightsAreaChartWidget.tsx b/static/app/views/insights/common/components/insightsAreaChartWidget.tsx index ecec7025e3c176..0a2cbb46dfe83f 100644 --- a/static/app/views/insights/common/components/insightsAreaChartWidget.tsx +++ b/static/app/views/insights/common/components/insightsAreaChartWidget.tsx @@ -8,12 +8,11 @@ import { AreaChartWidget, type AreaChartWidgetProps, } from 'sentry/views/dashboards/widgets/areaChartWidget/areaChartWidget'; -import {AreaChartWidgetSeries} from 'sentry/views/dashboards/widgets/areaChartWidget/areaChartWidgetSeries'; -import type {Aliases} from 'sentry/views/dashboards/widgets/common/types'; import { - TimeSeriesWidgetVisualization, - type TimeSeriesWidgetVisualizationProps, -} from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; + AreaChartWidgetVisualization, + type AreaChartWidgetVisualizationProps, +} from 'sentry/views/dashboards/widgets/areaChartWidget/areaChartWidgetVisualization'; +import type {Aliases} from 'sentry/views/dashboards/widgets/common/types'; import {THROUGHPUT_COLOR} from '../../colors'; import type {DiscoverSeries} from '../queries/useDiscoverSeries'; @@ -31,8 +30,7 @@ export function InsightsAreaChartWidget(props: InsightsAreaChartWidgetProps) { const {start, end, period, utc} = pageFilters.selection.datetime; const {projects, environments} = pageFilters.selection; - const visualizationProps: TimeSeriesWidgetVisualizationProps = { - SeriesConstructor: AreaChartWidgetSeries, + const visualizationProps: AreaChartWidgetVisualizationProps = { timeseries: (props.series.filter(Boolean) ?? [])?.map(serie => { const timeserie = convertSeriesToTimeseries(serie); @@ -67,7 +65,7 @@ export function InsightsAreaChartWidget(props: InsightsAreaChartWidgetProps) { {({releases}) => { return ( - { const timeserie = convertSeriesToTimeseries(serie); @@ -74,7 +72,7 @@ export function InsightsLineChartWidget(props: InsightsLineChartWidgetProps) { {({releases}) => { return ( - { - return !requiredParams.some(paramName => Boolean(newLocation.query[paramName])); + if (!requiredParams.every(paramName => Boolean(newLocation.query[paramName]))) { + return true; + } + + if (newLocation.pathname.includes('/trace/')) { + return true; + } + + return false; }, [requiredParams] ); diff --git a/static/app/views/insights/mobile/appStarts/views/screenSummaryPage.tsx b/static/app/views/insights/mobile/appStarts/views/screenSummaryPage.tsx index 1a39078aae4939..2e269d573e59ba 100644 --- a/static/app/views/insights/mobile/appStarts/views/screenSummaryPage.tsx +++ b/static/app/views/insights/mobile/appStarts/views/screenSummaryPage.tsx @@ -21,6 +21,7 @@ import { SECONDARY_RELEASE_ALIAS, } from 'sentry/views/insights/common/components/releaseSelector'; import {ToolRibbon} from 'sentry/views/insights/common/components/ribbon'; +import {useReleaseSelection} from 'sentry/views/insights/common/queries/useReleases'; import {useSamplesDrawer} from 'sentry/views/insights/common/utils/useSamplesDrawer'; import {SamplesTables} from 'sentry/views/insights/mobile/appStarts/components/samples'; import { @@ -85,13 +86,13 @@ export function ScreenSummaryContentPage() { const location = useLocation(); const { - primaryRelease, - secondaryRelease, transaction: transactionName, spanGroup, [SpanMetricsField.APP_START_TYPE]: appStartType, } = location.query; + const {primaryRelease, secondaryRelease} = useReleaseSelection(); + useEffect(() => { // Default the start type to cold start if not present if (!appStartType) { diff --git a/static/app/views/insights/mobile/screenload/views/screenLoadSpansPage.tsx b/static/app/views/insights/mobile/screenload/views/screenLoadSpansPage.tsx index 53a46c05b3f3d4..c1e52b4042bdc9 100644 --- a/static/app/views/insights/mobile/screenload/views/screenLoadSpansPage.tsx +++ b/static/app/views/insights/mobile/screenload/views/screenLoadSpansPage.tsx @@ -20,6 +20,7 @@ import { SECONDARY_RELEASE_ALIAS, } from 'sentry/views/insights/common/components/releaseSelector'; import {ToolRibbon} from 'sentry/views/insights/common/components/ribbon'; +import {useReleaseSelection} from 'sentry/views/insights/common/queries/useReleases'; import {useSamplesDrawer} from 'sentry/views/insights/common/utils/useSamplesDrawer'; import {QueryParameterNames} from 'sentry/views/insights/common/views/queryParameters'; import {SpanSamplesPanel} from 'sentry/views/insights/mobile/common/components/spanSamplesPanel'; @@ -82,12 +83,8 @@ export function ScreenLoadSpansContent() { const router = useRouter(); const location = useLocation(); - const { - spanGroup, - primaryRelease, - secondaryRelease, - transaction: transactionName, - } = location.query; + const {spanGroup, transaction: transactionName} = location.query; + const {primaryRelease, secondaryRelease} = useReleaseSelection(); useSamplesDrawer({ Component: ( @@ -172,21 +169,25 @@ export function ScreenLoadSpansContent() { /> - + {primaryRelease && ( + + )} - + {secondaryRelease && ( + + )} { - if (metric.value == null) { + if (metric.value === undefined) { return '-'; } if (typeof metric.value === 'number' && metric.type === 'duration' && metric.unit) { diff --git a/static/app/views/issueDetails/groupSidebar.tsx b/static/app/views/issueDetails/groupSidebar.tsx index 20dfdde9442359..a863b2f43ac7a7 100644 --- a/static/app/views/issueDetails/groupSidebar.tsx +++ b/static/app/views/issueDetails/groupSidebar.tsx @@ -3,10 +3,10 @@ import styled from '@emotion/styled'; import AvatarList from 'sentry/components/avatar/avatarList'; import {DateTime} from 'sentry/components/dateTime'; -import type {OnAssignCallback} from 'sentry/components/deprecatedAssigneeSelectorDropdown'; import ErrorBoundary from 'sentry/components/errorBoundary'; import {EventThroughput} from 'sentry/components/events/eventStatisticalDetector/eventThroughput'; import AssignedTo from 'sentry/components/group/assignedTo'; +import type {OnAssignCallback} from 'sentry/components/group/assigneeSelector'; import ExternalIssueList from 'sentry/components/group/externalIssuesList'; import GroupReleaseStats from 'sentry/components/group/releaseStats'; import TagFacets, { diff --git a/static/app/views/issueList/utils.tsx b/static/app/views/issueList/utils.tsx index a43e94f937bd4d..97a1750443cfad 100644 --- a/static/app/views/issueList/utils.tsx +++ b/static/app/views/issueList/utils.tsx @@ -10,7 +10,7 @@ import type {Organization} from 'sentry/types/organization'; export enum Query { FOR_REVIEW = 'is:unresolved is:for_review assigned_or_suggested:[me, my_teams, none]', // biome-ignore lint/style/useLiteralEnumMembers: Disable for maintenance cost. - PRIORITIZED = DEFAULT_QUERY, + PRIORITIZED = DEFAULT_QUERY, // eslint-disable-line @typescript-eslint/prefer-literal-enum-member UNRESOLVED = 'is:unresolved', IGNORED = 'is:ignored', NEW = 'is:new', diff --git a/static/app/views/onboarding/onboarding.spec.tsx b/static/app/views/onboarding/onboarding.spec.tsx index e150eca9b2c855..11f2b5099f8759 100644 --- a/static/app/views/onboarding/onboarding.spec.tsx +++ b/static/app/views/onboarding/onboarding.spec.tsx @@ -42,7 +42,6 @@ describe('Onboarding', function () { ); expect(screen.getByLabelText('Start')).toBeInTheDocument(); - expect(screen.getByLabelText('Invite Team')).toBeInTheDocument(); }); it('renders the select platform step', async function () { diff --git a/static/app/views/onboarding/welcome.tsx b/static/app/views/onboarding/welcome.tsx index 5d8923f591952f..7cd93fbee935ec 100644 --- a/static/app/views/onboarding/welcome.tsx +++ b/static/app/views/onboarding/welcome.tsx @@ -4,13 +4,11 @@ import type {MotionProps} from 'framer-motion'; import {motion} from 'framer-motion'; import OnboardingInstall from 'sentry-images/spot/onboarding-install.svg'; -import OnboardingSetup from 'sentry-images/spot/onboarding-setup.svg'; -import {openInviteMembersModal} from 'sentry/actionCreators/modal'; import {Button} from 'sentry/components/button'; import Link from 'sentry/components/links/link'; import {OnboardingContext} from 'sentry/components/onboarding/onboardingContext'; -import {t, tct} from 'sentry/locale'; +import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {trackAnalytics} from 'sentry/utils/analytics'; import testableTransition from 'sentry/utils/testableTransition'; @@ -115,26 +113,6 @@ function TargetedOnboardingWelcome(props: StepProps) { } /> - - {t('friends')}} - )} - src={OnboardingSetup} - cta={ - { - openInviteMembersModal({source}); - }} - priority="primary" - > - {t('Invite Team')} - - } - /> - {t("Gee, I've used Sentry before.")}
@@ -207,10 +185,6 @@ const TextWrapper = styled('div')` } `; -const Strike = styled('span')` - text-decoration: line-through; -`; - const ActionTitle = styled('h5')` font-weight: ${p => p.theme.fontWeightBold}; margin: 0 0 ${space(0.5)}; diff --git a/static/app/views/performance/newTraceDetails/issuesTraceWaterfall.tsx b/static/app/views/performance/newTraceDetails/issuesTraceWaterfall.tsx index 227232c06aab2c..8c09705e2ec02f 100644 --- a/static/app/views/performance/newTraceDetails/issuesTraceWaterfall.tsx +++ b/static/app/views/performance/newTraceDetails/issuesTraceWaterfall.tsx @@ -353,7 +353,7 @@ const IssuesPointerDisabled = styled('div')` const ROW_HEIGHT = 24; const MIN_ROW_COUNT = 1; -const HEADER_HEIGHT = 28; +const HEADER_HEIGHT = 38; const MAX_HEIGHT = 12 * ROW_HEIGHT + HEADER_HEIGHT; const MAX_ROW_COUNT = Math.floor(MAX_HEIGHT / ROW_HEIGHT); diff --git a/static/app/views/performance/newTraceDetails/trace.tsx b/static/app/views/performance/newTraceDetails/trace.tsx index 0f50936513851a..595eb9bac88f9d 100644 --- a/static/app/views/performance/newTraceDetails/trace.tsx +++ b/static/app/views/performance/newTraceDetails/trace.tsx @@ -13,6 +13,7 @@ import styled from '@emotion/styled'; import {Tooltip} from 'sentry/components/tooltip'; import ConfigStore from 'sentry/stores/configStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import type {PlatformKey, Project} from 'sentry/types/project'; @@ -136,6 +137,7 @@ export function Trace({ const organization = useOrganization(); const traceState = useTraceState(); const traceDispatch = useTraceStateDispatch(); + const {theme: colorMode} = useLegacyStore(ConfigStore); const rerenderRef = useRef(rerender); rerenderRef.current = rerender; @@ -425,7 +427,7 @@ export function Trace({
manager.registerIndicatorLabelRef(r, i, indicator)} - className={`TraceIndicatorLabelContainer ${status}`} + className={`TraceIndicatorLabelContainer ${status} ${colorMode}`} > p.theme.red300}; border: 1px solid ${p => p.theme.red300}; + + &.light { + background-color: rgb(251 232 233); + } + + &.dark { + background-color: rgb(63 17 20); + } } &.Meh { color: ${p => p.theme.yellow400}; border: 1px solid ${p => p.theme.yellow300}; + + &.light { + background-color: rgb(249 244 224); + } + + &.dark { + background-color: rgb(45 41 17); + } } &.Good { color: ${p => p.theme.green300}; border: 1px solid ${p => p.theme.green300}; + + &.light { + background-color: rgb(232 241 239); + } + + &.dark { + background-color: rgb(9 37 30); + } } } diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/index.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/index.tsx index 2811f84a8ab067..5d04387d7b6971 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/span/index.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/span/index.tsx @@ -194,14 +194,18 @@ function LegacySpanSections({ } function ProfileDetails({ + organization, + project, event, span, }: { event: Readonly; + organization: Organization; + project: Project | undefined; span: Readonly; }) { const hasNewTraceUi = useHasTraceNewUi(); - const {profile, frames} = useSpanProfileDetails(event, span); + const {profile, frames} = useSpanProfileDetails(organization, project, event, span); if (!hasNewTraceUi) { return ; @@ -238,7 +242,9 @@ export function SpanNodeDetails({ }, [node.errors, node.performance_issues]); const project = projects.find(proj => proj.slug === node.event?.projectSlug); - const profileId = node.event?.contexts?.profile?.profile_id ?? null; + const profileMeta = getProfileMeta(node) || ''; + const profileId = + typeof profileMeta === 'string' ? profileMeta : profileMeta.profiler_id; return ( @@ -253,7 +259,7 @@ export function SpanNodeDetails({ {profiles => ( @@ -280,7 +286,12 @@ export function SpanNodeDetails({ onParentClick={onParentClick} /> {organization.features.includes('profiling') ? ( - + ) : null} )} @@ -291,3 +302,21 @@ export function SpanNodeDetails({ ); } + +function getProfileMeta(node: TraceTreeNode) { + const profileId = node.event?.contexts?.profile?.profile_id; + if (profileId) { + return profileId; + } + const profilerId = node.event?.contexts?.profile?.profiler_id; + if (profilerId) { + const start = new Date(node.value.start_timestamp * 1000); + const end = new Date(node.value.timestamp * 1000); + return { + profiler_id: profilerId, + start: start.toISOString(), + end: end.toISOString(), + }; + } + return null; +} diff --git a/static/app/views/projectDetail/projectScoreCards/actionWrapper.tsx b/static/app/views/projectDetail/projectScoreCards/actionWrapper.tsx index 4fabc0e7e380b3..b5d2407853918c 100644 --- a/static/app/views/projectDetail/projectScoreCards/actionWrapper.tsx +++ b/static/app/views/projectDetail/projectScoreCards/actionWrapper.tsx @@ -3,5 +3,5 @@ import styled from '@emotion/styled'; import {space} from 'sentry/styles/space'; export const ActionWrapper = styled('div')` - padding: ${space(2)}; + padding: ${space(1)} 0 0 0; `; diff --git a/static/app/views/replays/detail/network/details/components.tsx b/static/app/views/replays/detail/network/details/components.tsx index 3a4e9ce52a28b3..be1278c1fd2aff 100644 --- a/static/app/views/replays/detail/network/details/components.tsx +++ b/static/app/views/replays/detail/network/details/components.tsx @@ -9,7 +9,8 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; export const Indent = styled('div')` - padding-left: ${space(4)}; + padding-left: ${space(1)}; + padding-right: ${space(1)}; `; export const InspectorMargin = styled('div')` diff --git a/static/app/views/replays/detail/network/details/onboarding.tsx b/static/app/views/replays/detail/network/details/onboarding.tsx index 147b75830f7dfe..7e70d60abaa8d4 100644 --- a/static/app/views/replays/detail/network/details/onboarding.tsx +++ b/static/app/views/replays/detail/network/details/onboarding.tsx @@ -74,7 +74,20 @@ export function Setup({ const url = item.description || 'http://example.com'; - return isVideoReplay ? null : ( + return isVideoReplay ? ( + visibleTab === 'request' || visibleTab === 'response' ? ( + + {tct( + 'Request and response headers or bodies are currently not available for mobile platforms. Track this [link:GitHub issue] to get progress on support for this feature.', + { + link: ( + + ), + } + )} + + ) : null + ) : ( { + return tempestCredentials?.filter( + credential => credential.messageType === MessageType.ERROR && credential.message + ); + }, [tempestCredentials]); + if (!hasTempestAccess(organization)) { return {t("You don't have access to this feature")}; } @@ -72,6 +81,19 @@ export default function TempestSettings({organization, project}: Props) { action={addNewCredentials(hasWriteAccess, organization, project)} /> + {credentialErrors && credentialErrors?.length > 0 && ( + + {t('There was a problem with following credentials:')} + + {credentialErrors.map(credential => ( + + {credential.clientId} - {credential.message} + + ))} + + + )} +
); + // Store Minidumps As Attachments + expect( + screen.getByRole('textbox', { + name: 'Store Minidumps As Attachments', + }) + ).not.toHaveValue(); + expect(screen.getByText(/Inherit organization settings/)).toBeInTheDocument(); + expect( screen.getByRole('checkbox', { name: 'Enable server-side data scrubbing', diff --git a/static/app/views/settings/projectSecurityHeaders/csp.tsx b/static/app/views/settings/projectSecurityHeaders/csp.tsx index 4c44699e08b204..18772ec239effc 100644 --- a/static/app/views/settings/projectSecurityHeaders/csp.tsx +++ b/static/app/views/settings/projectSecurityHeaders/csp.tsx @@ -7,7 +7,6 @@ import LoadingIndicator from 'sentry/components/loadingIndicator'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelHeader from 'sentry/components/panels/panelHeader'; -import PreviewFeature from 'sentry/components/previewFeature'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import formGroups from 'sentry/data/forms/cspReports'; import {t, tct} from 'sentry/locale'; @@ -92,8 +91,6 @@ export default function ProjectCspReports() { /> - - ) } subtitle={ - !tabDebugIdBundlesActive && ( - {params.bundleId} - ) + !isDebugIdBundle && {params.bundleId} } /> - {tabDebugIdBundlesActive && debugIdBundlesArtifactsData && ( + {isDebugIdBundle && debugIdBundlesArtifactsData && ( )} {t('Type')}] - : []), + ...(isDebugIdBundle ? [{t('Type')}] : []), {t('File Size')}, '', ]} @@ -233,16 +222,14 @@ export function SourceMapsDetails({params, location, router, project}: Props) { : t('There are no artifacts in this upload.') } isEmpty={ - (tabDebugIdBundlesActive + (isDebugIdBundle ? debugIdBundlesArtifactsData?.files ?? [] : artifactsData ?? [] ).length === 0 } - isLoading={ - tabDebugIdBundlesActive ? debugIdBundlesArtifactsLoading : artifactsLoading - } + isLoading={isDebugIdBundle ? debugIdBundlesArtifactsLoading : artifactsLoading} > - {tabDebugIdBundlesActive + {isDebugIdBundle ? (debugIdBundlesArtifactsData?.files ?? []).map(data => { const downloadUrl = `${api.baseUrl}/projects/${organization.slug}/${ project.slug @@ -311,7 +298,7 @@ export function SourceMapsDetails({params, location, router, project}: Props) { = {}): Project { sensitiveFields: [], subjectTemplate: '', verifySSL: false, + storeCrashReports: null, ...params, }; } diff --git a/tests/js/sentry-test/loadFixtures.ts b/tests/js/sentry-test/loadFixtures.ts index 7a5f0031c69658..910478b41c870d 100644 --- a/tests/js/sentry-test/loadFixtures.ts +++ b/tests/js/sentry-test/loadFixtures.ts @@ -1,4 +1,5 @@ -/* global __dirname */ +'use strict'; + import fs from 'node:fs'; import path from 'node:path'; diff --git a/tests/js/setup.ts b/tests/js/setup.ts index c75a70621b8602..bce5f2fb01d0bc 100644 --- a/tests/js/setup.ts +++ b/tests/js/setup.ts @@ -1,6 +1,6 @@ +'use strict'; import '@testing-library/jest-dom'; -/* eslint-env node */ import type {ReactElement} from 'react'; import {configure as configureRtl} from '@testing-library/react'; // eslint-disable-line no-restricted-imports import {enableFetchMocks} from 'jest-fetch-mock'; @@ -159,10 +159,12 @@ declare global { /** * Generates a promise that resolves on the next macro-task */ + // eslint-disable-next-line no-var var tick: () => Promise; /** * Used to mock API requests */ + // eslint-disable-next-line no-var var MockApiClient: typeof Client; } diff --git a/tests/sentry/api/endpoints/test_group_autofix_update.py b/tests/sentry/api/endpoints/test_group_autofix_update.py index 3170089af6b26c..a6dfaf346b8503 100644 --- a/tests/sentry/api/endpoints/test_group_autofix_update.py +++ b/tests/sentry/api/endpoints/test_group_autofix_update.py @@ -4,7 +4,7 @@ from django.conf import settings from rest_framework import status -from sentry.seer.signed_seer_api import get_seer_salted_url, sign_with_seer_secret +from sentry.seer.signed_seer_api import sign_with_seer_secret from sentry.testutils.cases import APITestCase @@ -47,10 +47,9 @@ def test_autofix_update_successful(self, mock_post): } ) expected_url = f"{settings.SEER_AUTOFIX_URL}/v1/automation/autofix/update" - expected_url, salt = get_seer_salted_url(expected_url) expected_headers = { "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(salt, body=expected_body), + **sign_with_seer_secret(expected_body), } mock_post.assert_called_once_with( expected_url, diff --git a/tests/sentry/api/endpoints/test_project_details.py b/tests/sentry/api/endpoints/test_project_details.py index cc676b3ca31213..8d46c97e69958c 100644 --- a/tests/sentry/api/endpoints/test_project_details.py +++ b/tests/sentry/api/endpoints/test_project_details.py @@ -1051,6 +1051,11 @@ def test_store_crash_reports_exceeded(self): assert self.project.get_option("sentry:store_crash_reports") is None assert b"storeCrashReports" in resp.content + def test_store_crash_reports_inherit_organization_settings(self): + resp = self.get_success_response(self.org_slug, self.proj_slug, storeCrashReports=None) + assert self.project.get_option("sentry:store_crash_reports") is None + assert resp.data["storeCrashReports"] is None + def test_react_hydration_errors(self): options = {"filters:react-hydration-errors": False} resp = self.get_success_response(self.org_slug, self.proj_slug, options=options) diff --git a/tests/sentry/api/endpoints/test_project_platforms.py b/tests/sentry/api/endpoints/test_project_platforms.py deleted file mode 100644 index 047570e40d1d01..00000000000000 --- a/tests/sentry/api/endpoints/test_project_platforms.py +++ /dev/null @@ -1,13 +0,0 @@ -from sentry.models.projectplatform import ProjectPlatform -from sentry.testutils.cases import APITestCase - - -class ProjectPlatformsTest(APITestCase): - def test_simple(self): - project = self.create_project() - self.login_as(user=self.user) - pp1 = ProjectPlatform.objects.create(project_id=project.id, platform="javascript") - url = f"/api/0/projects/{project.organization.slug}/{project.slug}/platforms/" - response = self.client.get(url, format="json") - assert response.status_code == 200, response.content - assert response.data[0]["platform"] == pp1.platform diff --git a/tests/sentry/api/serializers/test_group_stream.py b/tests/sentry/api/serializers/test_group_stream.py index c629df25dc4f04..aafeb7795b9a4a 100644 --- a/tests/sentry/api/serializers/test_group_stream.py +++ b/tests/sentry/api/serializers/test_group_stream.py @@ -21,7 +21,9 @@ def test_environment(self): environment = Environment.get_or_create(group.project, "production") - with mock.patch("sentry.tsdb.get_range", side_effect=tsdb.backend.get_range) as get_range: + with mock.patch( + "sentry.tsdb.backend.get_range", side_effect=tsdb.backend.get_range + ) as get_range: serialize( [group], serializer=StreamGroupSerializer( @@ -36,7 +38,7 @@ def get_invalid_environment(): raise Environment.DoesNotExist() with mock.patch( - "sentry.tsdb.make_series", + "sentry.tsdb.backend.make_series", side_effect=tsdb.backend.make_series, ) as make_series: serialize( diff --git a/tests/sentry/event_manager/test_severity.py b/tests/sentry/event_manager/test_severity.py index 0b6445abacefd2..2cc5a7b30edde8 100644 --- a/tests/sentry/event_manager/test_severity.py +++ b/tests/sentry/event_manager/test_severity.py @@ -67,7 +67,7 @@ def test_error_event_simple(self, mock_urlopen: MagicMock) -> None: mock_urlopen.assert_called_with( "POST", - "/v0/issues/severity-score?", + "/v0/issues/severity-score", body=orjson.dumps(payload), headers={"content-type": "application/json;charset=utf-8"}, timeout=0.2, @@ -83,11 +83,11 @@ def test_error_event_simple(self, mock_urlopen: MagicMock) -> None: _get_severity_score(event) mock_urlopen.assert_called_with( "POST", - "/v0/issues/severity-score?", + "/v0/issues/severity-score", body=orjson.dumps(payload), headers={ "content-type": "application/json;charset=utf-8", - "Authorization": "Rpcsignature rpc0:8d982376e4e49ffe845ed39853f6f2cb9bf38564d2a8a325dcd88abba8c58564", + "Authorization": "Rpcsignature rpc0:b14214093c3e7c633e68ac90b01087e710fe2f96c0544b232b9ec9bc6ca971f4", }, timeout=0.2, ) @@ -119,7 +119,7 @@ def test_message_event_simple( mock_urlopen.assert_called_with( "POST", - "/v0/issues/severity-score?", + "/v0/issues/severity-score", body=orjson.dumps(payload), headers={"content-type": "application/json;charset=utf-8"}, timeout=0.2, diff --git a/tests/sentry/incidents/action_handlers/__init__.py b/tests/sentry/incidents/action_handlers/__init__.py index f7bf3edc927be7..186c87d37e1996 100644 --- a/tests/sentry/incidents/action_handlers/__init__.py +++ b/tests/sentry/incidents/action_handlers/__init__.py @@ -13,15 +13,13 @@ class FireTest(TestCase, abc.ABC): def run_test(self, incident: Incident, method: str, **kwargs): pass - def run_fire_test(self, method="fire", chart_url=None): + def run_fire_test(self, method="fire", chart_url=None, status=IncidentStatus.CLOSED): kwargs = {} if chart_url: kwargs = {"chart_url": chart_url} self.alert_rule = self.create_alert_rule() - incident = self.create_incident( - alert_rule=self.alert_rule, status=IncidentStatus.CLOSED.value - ) + incident = self.create_incident(alert_rule=self.alert_rule, status=status.value) if method == "resolve": update_incident_status( incident, IncidentStatus.CLOSED, status_method=IncidentStatusMethod.MANUAL diff --git a/tests/sentry/incidents/action_handlers/test_pagerduty.py b/tests/sentry/incidents/action_handlers/test_pagerduty.py index 49fa6f95605c71..5b47434b883077 100644 --- a/tests/sentry/incidents/action_handlers/test_pagerduty.py +++ b/tests/sentry/incidents/action_handlers/test_pagerduty.py @@ -260,3 +260,9 @@ def test_custom_severity(self): def test_custom_severity_resolved(self): self.action.update(sentry_app_config={"priority": "critical"}) self.run_fire_test("resolve") + + @responses.activate + def test_custom_severity_with_default_severity(self): + # default closed incident severity is info, setting severity to default should be ignored + self.action.update(sentry_app_config={"priority": "default"}) + self.run_fire_test(status=IncidentStatus.CRITICAL) diff --git a/tests/sentry/incidents/endpoints/test_organization_alert_rule_anomalies.py b/tests/sentry/incidents/endpoints/test_organization_alert_rule_anomalies.py index 218f05303740a2..933ca1dbe977eb 100644 --- a/tests/sentry/incidents/endpoints/test_organization_alert_rule_anomalies.py +++ b/tests/sentry/incidents/endpoints/test_organization_alert_rule_anomalies.py @@ -111,7 +111,7 @@ def test_simple(self, mock_seer_request, mock_seer_store_request): assert mock_seer_store_request.call_count == 1 assert mock_seer_request.call_count == 1 assert mock_seer_request.call_args.args[0] == "POST" - assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL + "?" + assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL assert resp.data == seer_return_value["timeseries"] @with_feature("organizations:anomaly-detection-alerts") diff --git a/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py b/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py index c455d5f08b8de0..8ad6f552e756fb 100644 --- a/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py +++ b/tests/sentry/incidents/endpoints/test_organization_alert_rule_index.py @@ -52,6 +52,8 @@ from sentry.testutils.skips import requires_snuba from tests.sentry.workflow_engine.migration_helpers.test_migrate_alert_rule import ( assert_alert_rule_migrated, + assert_alert_rule_resolve_trigger_migrated, + assert_alert_rule_trigger_migrated, ) pytestmark = [pytest.mark.sentry_metrics, requires_snuba] @@ -232,6 +234,7 @@ def test_create_alert_rule_aci(self): outbox_runner(), self.feature(["organizations:incidents", "organizations:performance-view"]), ): + self.alert_rule_dict["resolveThreshold"] = 50 resp = self.get_success_response( self.organization.slug, status_code=201, @@ -239,8 +242,12 @@ def test_create_alert_rule_aci(self): ) assert "id" in resp.data alert_rule = AlertRule.objects.get(id=resp.data["id"]) + triggers = AlertRuleTrigger.objects.filter(alert_rule_id=alert_rule.id) assert resp.data == serialize(alert_rule, self.user) assert_alert_rule_migrated(alert_rule, self.project.id) + assert_alert_rule_trigger_migrated(triggers[0]) + assert_alert_rule_trigger_migrated(triggers[1]) + assert_alert_rule_resolve_trigger_migrated(alert_rule) @with_feature("organizations:slack-metric-alert-description") @with_feature("organizations:incidents") diff --git a/tests/sentry/incidents/test_subscription_processor.py b/tests/sentry/incidents/test_subscription_processor.py index 281be19fd0c732..c19758df12e707 100644 --- a/tests/sentry/incidents/test_subscription_processor.py +++ b/tests/sentry/incidents/test_subscription_processor.py @@ -464,7 +464,7 @@ def test_seer_call(self, mock_seer_request: MagicMock): processor = self.send_update(rule, 5, timedelta(minutes=-3)) assert mock_seer_request.call_args.args[0] == "POST" - assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL + "?" + assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL deserialized_body = json.loads(mock_seer_request.call_args.kwargs["body"]) assert deserialized_body["organization_id"] == self.sub.project.organization.id assert deserialized_body["project_id"] == self.sub.project_id @@ -505,7 +505,7 @@ def test_seer_call(self, mock_seer_request: MagicMock): processor = self.send_update(rule, 10, timedelta(minutes=-2)) assert mock_seer_request.call_args.args[0] == "POST" - assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL + "?" + assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL deserialized_body = json.loads(mock_seer_request.call_args.kwargs["body"]) assert deserialized_body["organization_id"] == self.sub.project.organization.id assert deserialized_body["project_id"] == self.sub.project_id @@ -543,7 +543,7 @@ def test_seer_call(self, mock_seer_request: MagicMock): processor = self.send_update(rule, 1, timedelta(minutes=-1)) assert mock_seer_request.call_args.args[0] == "POST" - assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL + "?" + assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL deserialized_body = json.loads(mock_seer_request.call_args.kwargs["body"]) assert deserialized_body["organization_id"] == self.sub.project.organization.id assert deserialized_body["project_id"] == self.sub.project_id @@ -592,7 +592,7 @@ def test_seer_call_performance_rule(self, mock_seer_request: MagicMock): processor = self.send_update(throughput_rule, 10, timedelta(minutes=-2)) assert mock_seer_request.call_args.args[0] == "POST" - assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL + "?" + assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL deserialized_body = json.loads(mock_seer_request.call_args.kwargs["body"]) assert deserialized_body["organization_id"] == self.sub.project.organization.id assert deserialized_body["project_id"] == self.sub.project_id @@ -633,7 +633,7 @@ def test_seer_call_performance_rule(self, mock_seer_request: MagicMock): processor = self.send_update(throughput_rule, 1, timedelta(minutes=-1)) assert mock_seer_request.call_args.args[0] == "POST" - assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL + "?" + assert mock_seer_request.call_args.args[1] == SEER_ANOMALY_DETECTION_ENDPOINT_URL deserialized_body = json.loads(mock_seer_request.call_args.kwargs["body"]) assert deserialized_body["organization_id"] == self.sub.project.organization.id assert deserialized_body["project_id"] == self.sub.project_id diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_serverless_functions.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_serverless_functions.py index c2ca11d0584240..976a66d3affe13 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_serverless_functions.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_serverless_functions.py @@ -253,7 +253,7 @@ def test_enable_node_layer(self, mock_gen_aws_client, mock_get_serialized_lambda ], "Environment": { "Variables": { - "NODE_OPTIONS": "-r @sentry/serverless/dist/awslambda-auto", + "NODE_OPTIONS": "-r @sentry/aws-serverless/awslambda-auto", "SENTRY_DSN": self.sentry_dsn, "SENTRY_TRACES_SAMPLE_RATE": "1.0", } @@ -286,7 +286,7 @@ def test_enable_node_layer(self, mock_gen_aws_client, mock_get_serialized_lambda ], Environment={ "Variables": { - "NODE_OPTIONS": "-r @sentry/serverless/dist/awslambda-auto", + "NODE_OPTIONS": "-r @sentry/aws-serverless/awslambda-auto", "SENTRY_DSN": self.sentry_dsn, "SENTRY_TRACES_SAMPLE_RATE": "1.0", } diff --git a/tests/sentry/integrations/aws_lambda/test_integration.py b/tests/sentry/integrations/aws_lambda/test_integration.py index f2ff544fe5846a..f15492700b03cd 100644 --- a/tests/sentry/integrations/aws_lambda/test_integration.py +++ b/tests/sentry/integrations/aws_lambda/test_integration.py @@ -14,6 +14,7 @@ from sentry.projects.services.project import project_service from sentry.silo.base import SiloMode from sentry.testutils.cases import IntegrationTestCase +from sentry.testutils.helpers.options import override_options from sentry.testutils.silo import assume_test_silo_mode, control_silo_test from sentry.users.services.user.serial import serialize_rpc_user @@ -157,67 +158,94 @@ def test_lambda_list(self, mock_react_view, mock_gen_aws_client, mock_get_suppor @patch("sentry.integrations.aws_lambda.integration.get_supported_functions") @patch("sentry.integrations.aws_lambda.integration.gen_aws_client") - def test_lambda_setup_layer_success(self, mock_gen_aws_client, mock_get_supported_functions): - mock_client = mock_gen_aws_client.return_value - mock_client.update_function_configuration = MagicMock() - mock_client.describe_account = MagicMock(return_value={"Account": {"Name": "my_name"}}) - - mock_get_supported_functions.return_value = [ - { - "FunctionName": "lambdaA", - "Runtime": "nodejs12.x", - "FunctionArn": "arn:aws:lambda:us-east-2:599817902985:function:lambdaA", - }, - { - "FunctionName": "lambdaB", - "Runtime": "nodejs10.x", - "FunctionArn": "arn:aws:lambda:us-east-2:599817902985:function:lambdaB", - }, - ] - - aws_external_id = "12-323" - self.pipeline.state.step_index = 2 - self.pipeline.state.data = { - "region": region, - "account_number": account_number, - "aws_external_id": aws_external_id, - "project_id": self.projectA.id, - } - - with assume_test_silo_mode(SiloMode.REGION): - sentry_project_dsn = ProjectKey.get_default(project=self.projectA).get_dsn(public=True) - - # TODO: pass in lambdaA=false - # having issues with reading json data - # request.POST looks like {"lambdaB": "True"} - # string instead of boolean - resp = self.client.post(self.setup_path, {"lambdaB": "true", "lambdaA": "false"}) - - assert resp.status_code == 200 - - mock_client.update_function_configuration.assert_called_once_with( - FunctionName="lambdaB", - Layers=["arn:aws:lambda:us-east-2:1234:layer:my-layer:3"], - Environment={ - "Variables": { - "NODE_OPTIONS": "-r @sentry/serverless/dist/awslambda-auto", - "SENTRY_DSN": sentry_project_dsn, - "SENTRY_TRACES_SAMPLE_RATE": "1.0", + def test_node_lambda_setup_layer_success( + self, + mock_gen_aws_client, + mock_get_supported_functions, + ): + for layer_name, layer_version, expected_node_options in [ + ("SentryNodeServerlessSDKv7", "5", "-r @sentry/serverless/dist/awslambda-auto"), + ("SentryNodeServerlessSDK", "168", "-r @sentry/serverless/dist/awslambda-auto"), + ("SentryNodeServerlessSDK", "235", "-r @sentry/serverless/dist/awslambda-auto"), + ("SentryNodeServerlessSDK", "236", "-r @sentry/aws-serverless/awslambda-auto"), + ("SentryNodeServerlessSDKv8", "3", "-r @sentry/aws-serverless/awslambda-auto"), + ("SentryNodeServerlessSDKv9", "235", "-r @sentry/aws-serverless/awslambda-auto"), + ]: + with override_options( + { + "aws-lambda.node.layer-name": layer_name, + "aws-lambda.node.layer-version": layer_version, + } + ): + # Ensure we reset everything + self.setUp() + mock_get_supported_functions.reset_mock() + mock_gen_aws_client.reset_mock() + + mock_client = mock_gen_aws_client.return_value + mock_client.update_function_configuration = MagicMock() + mock_client.describe_account = MagicMock( + return_value={"Account": {"Name": "my_name"}} + ) + + mock_get_supported_functions.return_value = [ + { + "FunctionName": "lambdaA", + "Runtime": "nodejs12.x", + "FunctionArn": "arn:aws:lambda:us-east-2:599817902985:function:lambdaA", + }, + { + "FunctionName": "lambdaB", + "Runtime": "nodejs10.x", + "FunctionArn": "arn:aws:lambda:us-east-2:599817902985:function:lambdaB", + }, + ] + + aws_external_id = "12-323" + self.pipeline.state.step_index = 2 + self.pipeline.state.data = { + "region": region, + "account_number": account_number, + "aws_external_id": aws_external_id, + "project_id": self.projectA.id, } - }, - ) - integration = Integration.objects.get(provider=self.provider.key) - assert integration.name == "my_name us-east-2" - assert integration.external_id == "599817902985-us-east-2" - assert integration.metadata == { - "region": region, - "account_number": account_number, - "aws_external_id": aws_external_id, - } - assert OrganizationIntegration.objects.filter( - integration=integration, organization_id=self.organization.id - ) + with assume_test_silo_mode(SiloMode.REGION): + sentry_project_dsn = ProjectKey.get_default(project=self.projectA).get_dsn( + public=True + ) + + # TODO: pass in lambdaA=false + # having issues with reading json data + # request.POST looks like {"lambdaB": "True"} + # string instead of boolean + resp = self.client.post(self.setup_path, {"lambdaB": "true", "lambdaA": "false"}) + + assert resp.status_code == 200 + + mock_client.update_function_configuration.assert_called_once_with( + FunctionName="lambdaB", + Layers=[f"arn:aws:lambda:us-east-2:1234:layer:{layer_name}:{layer_version}"], + Environment={ + "Variables": { + "NODE_OPTIONS": expected_node_options, + "SENTRY_DSN": sentry_project_dsn, + "SENTRY_TRACES_SAMPLE_RATE": "1.0", + } + }, + ) + + integration = Integration.objects.get(provider=self.provider.key) + assert integration.name == "my_name us-east-2" + assert integration.external_id == "599817902985-us-east-2" + assert integration.metadata == { + "region": region, + "account_number": account_number, + "aws_external_id": aws_external_id, + } + assert OrganizationIntegration.objects.filter( + integration=integration, organization_id=self.organization.id + ) @patch("sentry.integrations.aws_lambda.integration.get_supported_functions") @patch("sentry.integrations.aws_lambda.integration.gen_aws_client") diff --git a/tests/sentry/integrations/gitlab/test_integration.py b/tests/sentry/integrations/gitlab/test_integration.py index a40fea7e4df9dc..22a32e9a3a0ba7 100644 --- a/tests/sentry/integrations/gitlab/test_integration.py +++ b/tests/sentry/integrations/gitlab/test_integration.py @@ -328,7 +328,8 @@ def test_get_stacktrace_link_file_identity_not_valid(self): assert excinfo.value.code == 401 @responses.activate - def test_get_stacktrace_link_use_default_if_version_404(self): + @patch("sentry.integrations.utils.metrics.EventLifecycle.record_halt") + def test_get_stacktrace_link_use_default_if_version_404(self, mock_record_halt): self.assert_setup_flow() external_id = 4 integration = Integration.objects.get(provider=self.provider.key) @@ -364,6 +365,8 @@ def test_get_stacktrace_link_use_default_if_version_404(self): source_url == "https://gitlab.example.com/getsentry/example-repo/blob/master/README.md" ) + mock_record_halt.assert_called_once() + @responses.activate def test_get_commit_context_all_frames(self): self.assert_setup_flow() diff --git a/tests/sentry/integrations/repository/issue_alert/test_issue_alert_notification_message_repository.py b/tests/sentry/integrations/repository/issue_alert/test_issue_alert_notification_message_repository.py index 67c0f6cdf1cf08..af7bbcb4a90127 100644 --- a/tests/sentry/integrations/repository/issue_alert/test_issue_alert_notification_message_repository.py +++ b/tests/sentry/integrations/repository/issue_alert/test_issue_alert_notification_message_repository.py @@ -1,5 +1,8 @@ +from datetime import timedelta from uuid import uuid4 +from django.utils import timezone + from sentry.integrations.repository.issue_alert import ( IssueAlertNotificationMessage, IssueAlertNotificationMessageRepository, @@ -8,6 +11,7 @@ from sentry.models.rulefirehistory import RuleFireHistory from sentry.notifications.models.notificationmessage import NotificationMessage from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.datetime import freeze_time class BaseIssueAlertNotificationMessageRepositoryTest(TestCase): @@ -110,6 +114,33 @@ def test_when_parent_has_child(self) -> None: self.parent_notification_message ) + def test_returns_parent_notification_message_with_open_period_start(self) -> None: + open_period_start = timezone.now() + notification_with_period = NotificationMessage.objects.create( + rule_fire_history=self.rule_fire_history, + rule_action_uuid=self.action_uuid, + message_identifier="789xyz", + open_period_start=open_period_start, + ) + + notification_with_period = NotificationMessage.objects.create( + rule_fire_history=self.rule_fire_history, + rule_action_uuid=self.action_uuid, + message_identifier="789xyz", + open_period_start=open_period_start + timedelta(seconds=1), + ) + + instance = self.repository.get_parent_notification_message( + rule_id=self.rule.id, + group_id=self.group.id, + rule_action_uuid=self.action_uuid, + open_period_start=open_period_start + timedelta(seconds=1), + ) + + assert instance is not None + assert instance == IssueAlertNotificationMessage.from_model(notification_with_period) + assert instance.open_period_start == open_period_start + timedelta(seconds=1) + class TestCreateNotificationMessage(BaseIssueAlertNotificationMessageRepositoryTest): def test_simple(self) -> None: @@ -230,3 +261,68 @@ def test_returns_filtered_messages_for_group_id(self) -> None: assert len(result_ids) == 1 assert result_ids[0] == self.parent_notification_message.id assert notification_message_that_should_not_be_returned.id not in result_ids + + @freeze_time("2025-01-01 00:00:00") + def test_returns_correct_message_when_open_period_start_is_not_none(self) -> None: + NotificationMessage.objects.create( + rule_fire_history=self.rule_fire_history, + rule_action_uuid=str(uuid4()), + message_identifier="period123", + open_period_start=timezone.now(), + ) + + n2 = NotificationMessage.objects.create( + rule_fire_history=self.rule_fire_history, + rule_action_uuid=str(uuid4()), + message_identifier="period123", + open_period_start=timezone.now() + timedelta(seconds=1), + ) + + n3 = NotificationMessage.objects.create( + rule_fire_history=self.rule_fire_history, + rule_action_uuid=str(uuid4()), + message_identifier="period123", + open_period_start=timezone.now() + timedelta(seconds=1), + ) + + result = list( + self.repository.get_all_parent_notification_messages_by_filters( + project_ids=[self.project.id], + group_ids=[self.group.id], + open_period_start=timezone.now() + timedelta(seconds=1), + ) + ) + + result_ids = [] + for parent_notification in result: + result_ids.append(parent_notification.id) + + assert len(result_ids) == 2 + assert n3.id in result_ids + assert n2.id in result_ids + + @freeze_time("2025-01-01 00:00:00") + def test_returns_none_when_open_period_start_does_not_match(self) -> None: + # Create notifications with different open periods + NotificationMessage.objects.create( + rule_fire_history=self.rule_fire_history, + rule_action_uuid=self.action_uuid, + message_identifier="period1", + open_period_start=timezone.now(), + ) + NotificationMessage.objects.create( + rule_fire_history=self.rule_fire_history, + rule_action_uuid=self.action_uuid, + message_identifier="period2", + open_period_start=timezone.now() + timedelta(days=1), + ) + + # Query with a different open period + instance = self.repository.get_parent_notification_message( + rule_id=self.rule.id, + group_id=self.group.id, + rule_action_uuid=self.action_uuid, + open_period_start=timezone.now() + timedelta(seconds=1), + ) + + assert instance is None diff --git a/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py b/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py index 742b7f783736c5..7dbe90cb7e5f72 100644 --- a/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py +++ b/tests/sentry/issues/endpoints/test_group_similar_issues_embeddings.py @@ -181,7 +181,7 @@ def test_simple( mock_seer_request.assert_called_with( "POST", - SEER_SIMILAR_ISSUES_URL + "?", + SEER_SIMILAR_ISSUES_URL, body=orjson.dumps(expected_seer_request_params), headers={"content-type": "application/json;charset=utf-8"}, ) @@ -602,7 +602,7 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None: mock_seer_request.assert_called_with( "POST", - SEER_SIMILAR_ISSUES_URL + "?", + SEER_SIMILAR_ISSUES_URL, body=orjson.dumps( { "threshold": 0.01, @@ -628,7 +628,7 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None: mock_seer_request.assert_called_with( "POST", - SEER_SIMILAR_ISSUES_URL + "?", + SEER_SIMILAR_ISSUES_URL, body=orjson.dumps( { "threshold": 0.01, @@ -657,7 +657,7 @@ def test_no_optional_params(self, mock_seer_request: mock.MagicMock) -> None: mock_seer_request.assert_called_with( "POST", - SEER_SIMILAR_ISSUES_URL + "?", + SEER_SIMILAR_ISSUES_URL, body=orjson.dumps( { "threshold": 0.01, diff --git a/tests/sentry/receivers/test_onboarding.py b/tests/sentry/receivers/test_onboarding.py index ea4e0ef2e29e63..aa6883fdd9dd96 100644 --- a/tests/sentry/receivers/test_onboarding.py +++ b/tests/sentry/receivers/test_onboarding.py @@ -14,7 +14,6 @@ from sentry.models.project import Project from sentry.models.rule import Rule from sentry.organizations.services.organization import organization_service -from sentry.plugins.bases.issue import IssueTrackingPlugin from sentry.signals import ( alert_rule_created, event_processed, @@ -22,10 +21,8 @@ first_replay_received, first_transaction_received, integration_added, - issue_tracker_used, member_invited, member_joined, - plugin_enabled, project_created, transaction_processed, ) @@ -274,33 +271,6 @@ def test_member_joined(self): ) assert task.data["invited_member_id"] == om.id - def test_issue_tracker_onboarding(self): - plugin_enabled.send( - plugin=IssueTrackingPlugin(), - project=self.project, - user=self.user, - sender=type(IssueTrackingPlugin), - ) - task = OrganizationOnboardingTask.objects.get( - organization=self.organization, - task=OnboardingTask.ISSUE_TRACKER, - status=OnboardingTaskStatus.PENDING, - ) - assert task is not None - - issue_tracker_used.send( - plugin=IssueTrackingPlugin(), - project=self.project, - user=self.user, - sender=type(IssueTrackingPlugin), - ) - task = OrganizationOnboardingTask.objects.get( - organization=self.organization, - task=OnboardingTask.ISSUE_TRACKER, - status=OnboardingTaskStatus.COMPLETE, - ) - assert task is not None - def test_alert_added(self): alert_rule_created.send( rule_id=Rule(id=1).id, diff --git a/tests/sentry/relay/snapshots/test_config/test_get_project_config/REGION.pysnap b/tests/sentry/relay/snapshots/test_config/test_get_project_config/REGION.pysnap index 3b1ffb275a22a1..99188fe8cd59ae 100644 --- a/tests/sentry/relay/snapshots/test_config/test_get_project_config/REGION.pysnap +++ b/tests/sentry/relay/snapshots/test_config/test_get_project_config/REGION.pysnap @@ -1,5 +1,5 @@ --- -created: '2024-07-19T07:45:13.262472+00:00' +created: '2025-01-21T09:32:06.138893+00:00' creator: sentry source: tests/sentry/relay/test_config.py --- @@ -128,13 +128,6 @@ config: window: granularitySeconds: 600 windowSeconds: 3600 - - id: profiles - limit: 10000 - namespace: profiles - scope: organization - window: - granularitySeconds: 600 - windowSeconds: 3600 performanceScore: profiles: - condition: diff --git a/tests/sentry/relay/snapshots/test_config/test_project_config_cardinality_limits/False/REGION.pysnap b/tests/sentry/relay/snapshots/test_config/test_project_config_cardinality_limits/False/REGION.pysnap index d6fae5ca205746..6420a574cac2e1 100644 --- a/tests/sentry/relay/snapshots/test_config/test_project_config_cardinality_limits/False/REGION.pysnap +++ b/tests/sentry/relay/snapshots/test_config/test_project_config_cardinality_limits/False/REGION.pysnap @@ -1,5 +1,5 @@ --- -created: '2024-04-24T14:41:11.777485+00:00' +created: '2025-01-21T09:32:15.967179+00:00' creator: sentry source: tests/sentry/relay/test_config.py --- @@ -32,13 +32,6 @@ cardinalityLimits: window: granularitySeconds: 400 windowSeconds: 4000 -- id: profiles - limit: 60 - namespace: profiles - scope: organization - window: - granularitySeconds: 600 - windowSeconds: 3600 - id: test3 limit: 90 scope: name diff --git a/tests/sentry/relay/snapshots/test_config/test_project_config_cardinality_limits/True/REGION.pysnap b/tests/sentry/relay/snapshots/test_config/test_project_config_cardinality_limits/True/REGION.pysnap index c6581fb2a29cee..0f569bcf682bc0 100644 --- a/tests/sentry/relay/snapshots/test_config/test_project_config_cardinality_limits/True/REGION.pysnap +++ b/tests/sentry/relay/snapshots/test_config/test_project_config_cardinality_limits/True/REGION.pysnap @@ -1,5 +1,5 @@ --- -created: '2024-05-03T06:37:35.696243+00:00' +created: '2025-01-21T09:32:16.237293+00:00' creator: sentry source: tests/sentry/relay/test_config.py --- @@ -36,14 +36,6 @@ cardinalityLimits: window: granularitySeconds: 400 windowSeconds: 4000 -- id: profiles - limit: 60 - namespace: profiles - passive: true - scope: organization - window: - granularitySeconds: 600 - windowSeconds: 3600 - id: test3 limit: 90 scope: name diff --git a/tests/sentry/search/eap/test_spans.py b/tests/sentry/search/eap/test_spans.py index 8077105b7d1ea0..af26d8b95664ca 100644 --- a/tests/sentry/search/eap/test_spans.py +++ b/tests/sentry/search/eap/test_spans.py @@ -23,7 +23,8 @@ ) from sentry.exceptions import InvalidSearchQuery -from sentry.search.eap.spans import SearchResolver +from sentry.search.eap.resolver import SearchResolver +from sentry.search.eap.span_columns import SPAN_DEFINITIONS from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SnubaParams from sentry.testutils.cases import TestCase @@ -31,7 +32,9 @@ class SearchResolverQueryTest(TestCase): def setUp(self): - self.resolver = SearchResolver(params=SnubaParams(), config=SearchResolverConfig()) + self.resolver = SearchResolver( + params=SnubaParams(), config=SearchResolverConfig(), definitions=SPAN_DEFINITIONS + ) def test_simple_query(self): where, having, _ = self.resolver.resolve_query("span.description:foo") @@ -528,7 +531,9 @@ def setUp(self): super().setUp() self.project = self.create_project(name="test") self.resolver = SearchResolver( - params=SnubaParams(projects=[self.project]), config=SearchResolverConfig() + params=SnubaParams(projects=[self.project]), + config=SearchResolverConfig(), + definitions=SPAN_DEFINITIONS, ) def test_simple_op_field(self): diff --git a/tests/sentry/seer/test_signed_seer_api.py b/tests/sentry/seer/test_signed_seer_api.py index 0a604441b47131..0eb3501d3fd90e 100644 --- a/tests/sentry/seer/test_signed_seer_api.py +++ b/tests/sentry/seer/test_signed_seer_api.py @@ -10,7 +10,11 @@ PATH = "/v0/some/url" -def run_test_case(path: str = PATH, timeout: int | None = None, shared_secret: str = "secret-one"): +def run_test_case( + path: str = PATH, + timeout: int | None = None, + shared_secret: str = "secret-one", +): """ Make a mock connection pool, call `make_signed_seer_api_request` on it, and return the pool's `urlopen` method, so we can make assertions on how `make_signed_seer_api_request` @@ -36,7 +40,7 @@ def test_simple(): mock_url_open = run_test_case() mock_url_open.assert_called_once_with( "POST", - PATH + "?", + PATH, body=REQUEST_BODY, headers={"content-type": "application/json;charset=utf-8"}, ) @@ -47,44 +51,24 @@ def test_uses_given_timeout(): mock_url_open = run_test_case(timeout=5) mock_url_open.assert_called_once_with( "POST", - PATH + "?", + PATH, body=REQUEST_BODY, headers={"content-type": "application/json;charset=utf-8"}, timeout=5, ) -@pytest.mark.django_db -@patch("sentry.seer.signed_seer_api.uuid4") -def test_uses_shared_secret_nonce(uuid_mock): - new_mock = MagicMock() - new_mock.hex = "1234" - uuid_mock.return_value = new_mock - - with override_options({"seer.api.use-shared-secret": 1.0, "seer.api.use-nonce-signature": 1.0}): - mock_url_open = run_test_case() - mock_url_open.assert_called_once_with( - "POST", - PATH + "?nonce=1234", - body=REQUEST_BODY, - headers={ - "content-type": "application/json;charset=utf-8", - "Authorization": "Rpcsignature rpc0:487fb810a4e87faf306dc9637cec9aaea2be37247410391b372178ffc15af6a8", - }, - ) - - @pytest.mark.django_db def test_uses_shared_secret(): with override_options({"seer.api.use-shared-secret": 1.0}): mock_url_open = run_test_case() mock_url_open.assert_called_once_with( "POST", - PATH + "?", + PATH, body=REQUEST_BODY, headers={ "content-type": "application/json;charset=utf-8", - "Authorization": "Rpcsignature rpc0:96f23d5b3df807a9dc91f090078a46c00e17fe8b0bc7ef08c9391fa8b37a66b5", + "Authorization": "Rpcsignature rpc0:d2e6070dfab955db6fc9f3bc0518f75f27ca93ae2e393072929e5f6cba26ff07", }, ) @@ -96,7 +80,7 @@ def test_uses_shared_secret_missing_secret(): mock_url_open.assert_called_once_with( "POST", - PATH + "?", + PATH, body=REQUEST_BODY, headers={"content-type": "application/json;charset=utf-8"}, ) diff --git a/tests/sentry/tasks/test_auto_source_code_config.py b/tests/sentry/tasks/test_auto_source_code_config.py index 77adea10c382c2..c41c48482df9a0 100644 --- a/tests/sentry/tasks/test_auto_source_code_config.py +++ b/tests/sentry/tasks/test_auto_source_code_config.py @@ -13,7 +13,7 @@ from sentry.shared_integrations.exceptions import ApiError from sentry.tasks.auto_source_code_config import ( DeriveCodeMappingsErrorReason, - derive_code_mappings, + auto_source_code_config, identify_stacktrace_paths, ) from sentry.testutils.asserts import assert_failure_metric, assert_halt_metric @@ -61,7 +61,10 @@ def test_does_not_raise_installation_removed(self, mock_record): "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org", side_effect=error, ): - assert derive_code_mappings(self.project.id, event_id=self.event.event_id) is None + assert ( + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) + is None + ) assert_halt_metric(mock_record, error) @patch("sentry.tasks.auto_source_code_config.logger") @@ -70,7 +73,7 @@ def test_raises_other_api_errors(self, mock_logger, mock_record): "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org", side_effect=ApiError("foo"), ): - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) assert mock_logger.error.call_count == 1 assert_halt_metric(mock_record, ApiError("foo")) @@ -80,7 +83,7 @@ def test_unable_to_get_lock(self, mock_record): "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org", side_effect=UnableToAcquireLock(), ): - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) assert not RepositoryProjectPathConfig.objects.exists() assert_failure_metric(mock_record, error) @@ -90,7 +93,7 @@ def test_raises_generic_errors(self, mock_logger, mock_record): "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org", side_effect=Exception("foo"), ): - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) assert_failure_metric(mock_record, DeriveCodeMappingsErrorReason.UNEXPECTED_ERROR) @@ -119,7 +122,7 @@ def test_backslash_filename_simple(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/mouse.py"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "\\" assert code_mapping.source_root == "" @@ -134,7 +137,7 @@ def test_backslash_drive_letter_filename_simple(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/tasks.py"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "C:sentry\\" assert code_mapping.source_root == "sentry/" @@ -149,7 +152,7 @@ def test_backslash_drive_letter_filename_monorepo(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["src/sentry/tasks.py"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "C:sentry\\" assert code_mapping.source_root == "src/sentry/" @@ -164,7 +167,7 @@ def test_backslash_drive_letter_filename_abs_path(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/models/release.py"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "D:\\Users\\code\\" assert code_mapping.source_root == "" @@ -223,7 +226,7 @@ def test_find_stacktrace_paths_bad_data(self): assert stacktrace_paths == [] @responses.activate - def test_derive_code_mappings_starts_with_period_slash(self): + def test_auto_source_code_config_starts_with_period_slash(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -234,7 +237,7 @@ def test_derive_code_mappings_starts_with_period_slash(self): ["static/app/utils/handleXhrErrorResponse.tsx"], ) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] # ./app/foo.tsx -> app/foo.tsx -> static/app/foo.tsx assert code_mapping.stack_root == "./" @@ -242,7 +245,7 @@ def test_derive_code_mappings_starts_with_period_slash(self): assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_starts_with_period_slash_no_containing_directory(self): + def test_auto_source_code_config_starts_with_period_slash_no_containing_directory(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -253,7 +256,7 @@ def test_derive_code_mappings_starts_with_period_slash_no_containing_directory(s ["app/utils/handleXhrErrorResponse.tsx"], ) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] # ./app/foo.tsx -> app/foo.tsx -> app/foo.tsx assert code_mapping.stack_root == "./" @@ -261,7 +264,7 @@ def test_derive_code_mappings_starts_with_period_slash_no_containing_directory(s assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_one_to_one_match(self): + def test_auto_source_code_config_one_to_one_match(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -269,7 +272,7 @@ def test_derive_code_mappings_one_to_one_match(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["some/path/Test.tsx"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] # some/path/Test.tsx -> Test.tsx -> some/path/Test.tsx assert code_mapping.stack_root == "" @@ -277,7 +280,7 @@ def test_derive_code_mappings_one_to_one_match(self): assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_same_trailing_substring(self): + def test_auto_source_code_config_same_trailing_substring(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -285,7 +288,7 @@ def test_derive_code_mappings_same_trailing_substring(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/app.tsx"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) assert not RepositoryProjectPathConfig.objects.exists() @@ -311,7 +314,7 @@ def test_find_stacktrace_paths_single_project(self): assert set(stacktrace_paths) == {"some/path/test.rb", "lib/tasks/crontask.rake"} @responses.activate - def test_derive_code_mappings_rb(self): + def test_auto_source_code_config_rb(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -319,14 +322,14 @@ def test_derive_code_mappings_rb(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["some/path/test.rb"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "" assert code_mapping.source_root == "" assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_rake(self): + def test_auto_source_code_config_rake(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -334,7 +337,7 @@ def test_derive_code_mappings_rake(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["lib/tasks/crontask.rake"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "" assert code_mapping.source_root == "" @@ -371,7 +374,7 @@ def test_find_stacktrace_paths_single_project(self): } @responses.activate - def test_derive_code_mappings_starts_with_app(self): + def test_auto_source_code_config_starts_with_app(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -379,13 +382,13 @@ def test_derive_code_mappings_starts_with_app(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["utils/errors.js"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "app:///" assert code_mapping.source_root == "" assert code_mapping.repository.name == repo_name - def test_derive_code_mappings_starts_with_app_complex(self): + def test_auto_source_code_config_starts_with_app_complex(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -393,14 +396,14 @@ def test_derive_code_mappings_starts_with_app_complex(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/utils/errors.js"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "app:///" assert code_mapping.source_root == "sentry/" assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_starts_with_multiple_dot_dot_slash(self): + def test_auto_source_code_config_starts_with_multiple_dot_dot_slash(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -408,14 +411,14 @@ def test_derive_code_mappings_starts_with_multiple_dot_dot_slash(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["packages/api/src/response.ts"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "../../../../../../" assert code_mapping.source_root == "" assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_starts_with_app_dot_dot_slash(self): + def test_auto_source_code_config_starts_with_app_dot_dot_slash(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -425,7 +428,7 @@ def test_derive_code_mappings_starts_with_app_dot_dot_slash(self): Repo(repo_name, "master"), ["services/event/EventLifecycle/index.js"] ) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "app:///../" assert code_mapping.source_root == "" @@ -456,7 +459,7 @@ def setUp(self): ) @responses.activate - def test_derive_code_mappings_go_abs_filename(self): + def test_auto_source_code_config_go_abs_filename(self): repo_name = "go_repo" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -464,14 +467,14 @@ def test_derive_code_mappings_go_abs_filename(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/capybara.go"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "/Users/JohnDoe/code/" assert code_mapping.source_root == "" assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_go_long_abs_filename(self): + def test_auto_source_code_config_go_long_abs_filename(self): repo_name = "go_repo" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -479,14 +482,14 @@ def test_derive_code_mappings_go_long_abs_filename(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/kangaroo.go"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "/Users/JohnDoe/Documents/code/" assert code_mapping.source_root == "" assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_similar_but_incorrect_file(self): + def test_auto_source_code_config_similar_but_incorrect_file(self): repo_name = "go_repo" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -494,7 +497,7 @@ def test_derive_code_mappings_similar_but_incorrect_file(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["notsentry/main.go"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) assert not RepositoryProjectPathConfig.objects.exists() @@ -515,7 +518,7 @@ def setUp(self): ) @responses.activate - def test_derive_code_mappings_basic_php(self): + def test_auto_source_code_config_basic_php(self): repo_name = "php/place" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -523,14 +526,14 @@ def test_derive_code_mappings_basic_php(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/potato/kangaroo.php"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "/" assert code_mapping.source_root == "" assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_different_roots_php(self): + def test_auto_source_code_config_different_roots_php(self): repo_name = "php/place" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -538,7 +541,7 @@ def test_derive_code_mappings_different_roots_php(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["src/sentry/potato/kangaroo.php"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "/sentry/" assert code_mapping.source_root == "src/sentry/" @@ -570,7 +573,7 @@ def setUp(self): ) @responses.activate - def test_derive_code_mappings_csharp_trivial(self): + def test_auto_source_code_config_csharp_trivial(self): repo_name = "csharp/repo" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -578,14 +581,14 @@ def test_derive_code_mappings_csharp_trivial(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/potato/kangaroo.cs"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "/" assert code_mapping.source_root == "" assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_different_roots_csharp(self): + def test_auto_source_code_config_different_roots_csharp(self): repo_name = "csharp/repo" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -593,14 +596,14 @@ def test_derive_code_mappings_different_roots_csharp(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["src/sentry/potato/kangaroo.cs"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "/sentry/" assert code_mapping.source_root == "src/sentry/" assert code_mapping.repository.name == repo_name @responses.activate - def test_derive_code_mappings_non_in_app_frame(self): + def test_auto_source_code_config_non_in_app_frame(self): repo_name = "csharp/repo" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -608,7 +611,7 @@ def test_derive_code_mappings_non_in_app_frame(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/src/functions.cs"]) } - derive_code_mappings(self.project.id, event_id=self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) assert not RepositoryProjectPathConfig.objects.exists() @@ -650,7 +653,7 @@ def test_handle_duplicate_filenames_in_stacktrace(self): ) ], ) - def test_derive_code_mappings_single_project( + def test_auto_source_code_config_single_project( self, mock_generate_code_mappings, mock_get_trees_for_org ): assert not RepositoryProjectPathConfig.objects.filter(project_id=self.project.id).exists() @@ -662,7 +665,7 @@ def test_derive_code_mappings_single_project( ) as mock_identify_stacktraces, self.tasks(), ): - derive_code_mappings(self.project.id, self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) assert mock_identify_stacktraces.call_count == 1 assert mock_get_trees_for_org.call_count == 1 @@ -672,7 +675,7 @@ def test_derive_code_mappings_single_project( def test_skips_not_supported_platforms(self): event = self.create_event([{}], platform="elixir") - assert derive_code_mappings(self.project.id, event.event_id) is None + assert auto_source_code_config(self.project.id, event.event_id, event.group_id) is None assert len(RepositoryProjectPathConfig.objects.filter(project_id=self.project.id)) == 0 @patch("sentry.integrations.github.integration.GitHubIntegration.get_trees_for_org") @@ -687,7 +690,7 @@ def test_skips_not_supported_platforms(self): ], ) @patch("sentry.tasks.auto_source_code_config.logger") - def test_derive_code_mappings_duplicates( + def test_auto_source_code_config_duplicates( self, mock_logger, mock_generate_code_mappings, mock_get_trees_for_org ): with assume_test_silo_mode_of(OrganizationIntegration): @@ -718,7 +721,7 @@ def test_derive_code_mappings_duplicates( ) as mock_identify_stacktraces, self.tasks(), ): - derive_code_mappings(self.project.id, self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) assert mock_identify_stacktraces.call_count == 1 assert mock_get_trees_for_org.call_count == 1 @@ -728,7 +731,7 @@ def test_derive_code_mappings_duplicates( assert mock_logger.info.call_count == 1 @responses.activate - def test_derive_code_mappings_stack_and_source_root_do_not_match(self): + def test_auto_source_code_config_stack_and_source_root_do_not_match(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -736,14 +739,14 @@ def test_derive_code_mappings_stack_and_source_root_do_not_match(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["src/sentry/models/release.py"]) } - derive_code_mappings(self.project.id, self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.get() # sentry/models/release.py -> models/release.py -> src/sentry/models/release.py assert code_mapping.stack_root == "sentry/" assert code_mapping.source_root == "src/sentry/" @responses.activate - def test_derive_code_mappings_no_normalization(self): + def test_auto_source_code_config_no_normalization(self): repo_name = "foo/bar" with patch( "sentry.integrations.github.client.GitHubBaseClient.get_trees_for_org" @@ -751,7 +754,7 @@ def test_derive_code_mappings_no_normalization(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/models/release.py"]) } - derive_code_mappings(self.project.id, self.event.event_id) + auto_source_code_config(self.project.id, self.event.event_id, self.event.group_id) code_mapping = RepositoryProjectPathConfig.objects.get() assert code_mapping.stack_root == "" diff --git a/tests/sentry/tasks/test_post_process.py b/tests/sentry/tasks/test_post_process.py index 25ea093d4941c4..031d28f4e4f80b 100644 --- a/tests/sentry/tasks/test_post_process.py +++ b/tests/sentry/tasks/test_post_process.py @@ -227,14 +227,14 @@ def _call_post_process_group(self, event: Event) -> None: event=event, ) - @patch("sentry.tasks.auto_source_code_config.derive_code_mappings") + @patch("sentry.tasks.auto_source_code_config.auto_source_code_config") def test_derive_invalid_platform(self, mock_derive_code_mappings): event = self._create_event({"platform": "elixir"}) self._call_post_process_group(event) assert mock_derive_code_mappings.delay.call_count == 0 - @patch("sentry.tasks.auto_source_code_config.derive_code_mappings") + @patch("sentry.tasks.auto_source_code_config.auto_source_code_config") def test_derive_supported_languages(self, mock_derive_code_mappings): for platform in SUPPORTED_LANGUAGES: event = self._create_event({"platform": platform}) @@ -242,33 +242,13 @@ def test_derive_supported_languages(self, mock_derive_code_mappings): assert mock_derive_code_mappings.delay.call_count == 1 - @patch("sentry.tasks.auto_source_code_config.derive_code_mappings") + @patch("sentry.tasks.auto_source_code_config.auto_source_code_config") def test_only_maps_a_given_project_once_per_hour(self, mock_derive_code_mappings): dogs_project = self.create_project() - maisey_event = self._create_event( - { - "fingerprint": ["themaiseymasieydog"], - }, - dogs_project.id, - ) - charlie_event = self._create_event( - { - "fingerprint": ["charliebear"], - }, - dogs_project.id, - ) - cory_event = self._create_event( - { - "fingerprint": ["thenudge"], - }, - dogs_project.id, - ) - bodhi_event = self._create_event( - { - "fingerprint": ["theescapeartist"], - }, - dogs_project.id, - ) + maisey_event = self._create_event({"fingerprint": ["themaiseymasieydog"]}, dogs_project.id) + charlie_event = self._create_event({"fingerprint": ["charliebear"]}, dogs_project.id) + cory_event = self._create_event({"fingerprint": ["thenudge"]}, dogs_project.id) + bodhi_event = self._create_event({"fingerprint": ["theescapeartist"]}, dogs_project.id) self._call_post_process_group(maisey_event) assert mock_derive_code_mappings.delay.call_count == 1 @@ -287,33 +267,13 @@ def test_only_maps_a_given_project_once_per_hour(self, mock_derive_code_mappings self._call_post_process_group(bodhi_event) assert mock_derive_code_mappings.delay.call_count == 2 - @patch("sentry.tasks.auto_source_code_config.derive_code_mappings") + @patch("sentry.tasks.auto_source_code_config.auto_source_code_config") def test_only_maps_a_given_issue_once_per_day(self, mock_derive_code_mappings): dogs_project = self.create_project() - maisey_event1 = self._create_event( - { - "fingerprint": ["themaiseymaiseydog"], - }, - dogs_project.id, - ) - maisey_event2 = self._create_event( - { - "fingerprint": ["themaiseymaiseydog"], - }, - dogs_project.id, - ) - maisey_event3 = self._create_event( - { - "fingerprint": ["themaiseymaiseydog"], - }, - dogs_project.id, - ) - maisey_event4 = self._create_event( - { - "fingerprint": ["themaiseymaiseydog"], - }, - dogs_project.id, - ) + maisey_event1 = self._create_event({"fingerprint": ["themaiseymaiseydog"]}, dogs_project.id) + maisey_event2 = self._create_event({"fingerprint": ["themaiseymaiseydog"]}, dogs_project.id) + maisey_event3 = self._create_event({"fingerprint": ["themaiseymaiseydog"]}, dogs_project.id) + maisey_event4 = self._create_event({"fingerprint": ["themaiseymaiseydog"]}, dogs_project.id) # because of the fingerprint, the events should always end up in the same group, # but the rest of the test is bogus if they aren't, so let's be sure assert maisey_event1.group_id == maisey_event2.group_id @@ -337,27 +297,12 @@ def test_only_maps_a_given_issue_once_per_day(self, mock_derive_code_mappings): self._call_post_process_group(maisey_event4) assert mock_derive_code_mappings.delay.call_count == 2 - @patch("sentry.tasks.auto_source_code_config.derive_code_mappings") + @patch("sentry.tasks.auto_source_code_config.auto_source_code_config") def test_skipping_an_issue_doesnt_mark_it_processed(self, mock_derive_code_mappings): dogs_project = self.create_project() - maisey_event = self._create_event( - { - "fingerprint": ["themaiseymasieydog"], - }, - dogs_project.id, - ) - charlie_event1 = self._create_event( - { - "fingerprint": ["charliebear"], - }, - dogs_project.id, - ) - charlie_event2 = self._create_event( - { - "fingerprint": ["charliebear"], - }, - dogs_project.id, - ) + maisey_event = self._create_event({"fingerprint": ["themaiseymasieydog"]}, dogs_project.id) + charlie_event1 = self._create_event({"fingerprint": ["charliebear"]}, dogs_project.id) + charlie_event2 = self._create_event({"fingerprint": ["charliebear"]}, dogs_project.id) # because of the fingerprint, the two Charlie events should always end up in the same group, # but the rest of the test is bogus if they aren't, so let's be sure assert charlie_event1.group_id == charlie_event2.group_id @@ -375,28 +320,6 @@ def test_skipping_an_issue_doesnt_mark_it_processed(self, mock_derive_code_mappi self._call_post_process_group(charlie_event2) assert mock_derive_code_mappings.delay.call_count == 2 - # XXX: Delete this test once we've migrated - @patch("sentry.tasks.auto_source_code_config.auto_source_code_config") - @patch("sentry.tasks.auto_source_code_config.derive_code_mappings") - def test_new_queue(self, mock_derive_code_mappings, mock_auto_source_code_config): - event = self._create_event(data={}, project_id=self.project.id) - - with self.options({"system.new-auto-source-code-config-queue": False}): - self._call_post_process_group(event) - assert mock_derive_code_mappings.delay.call_count == 1 - assert mock_auto_source_code_config.delay.call_count == 0 - - # XXX: Delete this test once we've migrated - @patch("sentry.tasks.auto_source_code_config.auto_source_code_config") - @patch("sentry.tasks.auto_source_code_config.derive_code_mappings") - def test_old_queue(self, mock_derive_code_mappings, mock_auto_source_code_config): - event = self._create_event(data={}, project_id=self.project.id) - - with self.options({"system.new-auto-source-code-config-queue": True}): - self._call_post_process_group(event) - assert mock_derive_code_mappings.delay.call_count == 0 - assert mock_auto_source_code_config.delay.call_count == 1 - class RuleProcessorTestMixin(BasePostProgressGroupMixin): @patch("sentry.rules.processing.processor.RuleProcessor") diff --git a/tests/sentry/utils/test_demo_mode.py b/tests/sentry/utils/test_demo_mode.py new file mode 100644 index 00000000000000..d60801ceb101fd --- /dev/null +++ b/tests/sentry/utils/test_demo_mode.py @@ -0,0 +1,88 @@ +from unittest.mock import patch + +from sentry.testutils.factories import Factories +from sentry.testutils.helpers.options import override_options +from sentry.testutils.pytest.fixtures import django_db_all +from sentry.utils.demo_mode import get_readonly_user, is_demo_org, is_readonly_user + + +@override_options({"demo-mode.enabled": True, "demo-mode.users": ["readonly@example.com"]}) +@django_db_all +def test_is_readonly_user_demo_mode_enabled_none(): + assert not is_readonly_user(None) + + +@override_options({"demo-mode.enabled": True, "demo-mode.users": ["readonly@example.com"]}) +@django_db_all +def test_is_readonly_user_demo_mode_enabled_readonly_user(): + user = Factories.create_user("readonly@example.com") + assert is_readonly_user(user) + + +@override_options({"demo-mode.enabled": True, "demo-mode.users": ["readonly@example.com"]}) +@django_db_all +def test_is_readonly_user_demo_mode_enabled_non_readonly_user(): + user = Factories.create_user("user@example.com") + assert not is_readonly_user(user) + + +@override_options({"demo-mode.enabled": False}) +@django_db_all +def test_is_readonly_user_demo_mode_disabled_none(): + assert not is_readonly_user(None) + + +@override_options({"demo-mode.enabled": False}) +@django_db_all +def test_is_readonly_user_demo_mode_disabled_readonly_user(): + user = Factories.create_user("readonly@example.com") + assert not is_readonly_user(user) + + +@override_options({"demo-mode.enabled": False}) +@django_db_all +def test_is_readonly_user_demo_mode_disabled_non_readonly_user(): + user = Factories.create_user("user@example.com") + assert not is_readonly_user(user) + + +@override_options({"demo-mode.enabled": False}) +@django_db_all +def test_is_demo_org_demo_mode_disabled(): + organization = Factories.create_organization() + assert not is_demo_org(organization) + + +@override_options({"demo-mode.enabled": True}) +@django_db_all +def test_is_demo_org_no_organization(): + assert not is_demo_org(None) + + +@override_options({"demo-mode.enabled": True, "demo-mode.orgs": [1, 2, 3]}) +@django_db_all +def test_is_demo_org_demo_mode_enabled(): + organization = Factories.create_organization(id=1) + assert is_demo_org(organization) + + +@override_options({"demo-mode.enabled": True, "demo-mode.orgs": [1, 2, 3]}) +@django_db_all +def test_is_demo_org_not_in_demo_orgs(): + organization = Factories.create_organization(id=4) + assert not is_demo_org(organization) + + +@override_options({"demo-mode.enabled": False}) +@django_db_all +def test_get_readonly_user_demo_mode_disabled(): + assert get_readonly_user() is None + + +@override_options({"demo-mode.enabled": True, "demo-mode.users": ["readonly@example.com"]}) +@django_db_all +def test_get_readonly_user_demo_mode_enabled(): + user = Factories.create_user("readonly@example.com") + with patch("sentry.utils.demo_mode.User.objects.get", return_value=user) as mock_user_get: + assert get_readonly_user() == user + mock_user_get.assert_called_once_with(email="readonly@example.com") diff --git a/tests/sentry/web/frontend/test_auth_organization_login.py b/tests/sentry/web/frontend/test_auth_organization_login.py index 9a43adb72152ad..9b280d926db1f0 100644 --- a/tests/sentry/web/frontend/test_auth_organization_login.py +++ b/tests/sentry/web/frontend/test_auth_organization_login.py @@ -880,9 +880,8 @@ def test_flow_as_authenticated_user_with_invite_joining(self): @override_settings(SENTRY_SINGLE_ORGANIZATION=True) @with_feature({"organizations:create": False}) - def test_basic_auth_flow_as_invited_user(self): + def test_basic_auth_flow_as_not_invited_user(self): user = self.create_user("foor@example.com") - self.create_member(organization=self.organization, email="foor@example.com") self.session["_next"] = reverse( "sentry-organization-settings", args=[self.organization.slug] @@ -896,9 +895,8 @@ def test_basic_auth_flow_as_invited_user(self): assert resp.status_code == 403 self.assertTemplateUsed(resp, "sentry/no-organization-access.html") - def test_basic_auth_flow_as_invited_user_not_single_org_mode(self): + def test_basic_auth_flow_as_not_invited_user_not_single_org_mode(self): user = self.create_user("u2@example.com") - self.create_member(organization=self.organization, email="u2@example.com") resp = self.client.post( self.path, {"username": user, "password": "admin", "op": "login"}, follow=True ) @@ -993,7 +991,8 @@ def test_correct_redirect_as_2fa_user_single_org_invited(self): self.path, {"username": user, "password": "admin", "op": "login"}, follow=True ) - assert resp.redirect_chain == [("/auth/2fa/", 302)] + invitation_link = "/" + member.get_invite_link().split("/", 3)[-1] + assert resp.redirect_chain == [(invitation_link, 302)] def test_correct_redirect_as_2fa_user_invited(self): user = self.create_user("foor@example.com") @@ -1012,7 +1011,8 @@ def test_correct_redirect_as_2fa_user_invited(self): self.path, {"username": user, "password": "admin", "op": "login"}, follow=True ) - assert resp.redirect_chain == [("/auth/2fa/", 302)] + invitation_link = "/" + member.get_invite_link().split("/", 3)[-1] + assert resp.redirect_chain == [(invitation_link, 302)] @override_settings(SENTRY_SINGLE_ORGANIZATION=True) @with_feature({"organizations:create": False}) diff --git a/tests/sentry/workflow_engine/handlers/condition/test_base.py b/tests/sentry/workflow_engine/handlers/condition/test_base.py index e30df91a8ca415..30bcd1b610ef2b 100644 --- a/tests/sentry/workflow_engine/handlers/condition/test_base.py +++ b/tests/sentry/workflow_engine/handlers/condition/test_base.py @@ -46,13 +46,6 @@ def assert_passes(self, data_condition: DataCondition, job: WorkflowJob) -> None def assert_does_not_pass(self, data_condition: DataCondition, job: WorkflowJob) -> None: assert data_condition.evaluate_value(job) != data_condition.get_condition_result() - # Slow conditions are evaluated in delayed processing and take in the results directly - def assert_slow_cond_passes(self, data_condition: DataCondition, value: Any) -> None: - assert data_condition.evaluate_value(value) == data_condition.get_condition_result() - - def assert_slow_cond_does_not_pass(self, data_condition: DataCondition, value: Any) -> None: - assert data_condition.evaluate_value(value) != data_condition.get_condition_result() - # TODO: activity diff --git a/tests/sentry/workflow_engine/handlers/condition/test_event_frequency_handlers.py b/tests/sentry/workflow_engine/handlers/condition/test_event_frequency_handlers.py index 483b7f71a32ee4..5fd4fc6c495462 100644 --- a/tests/sentry/workflow_engine/handlers/condition/test_event_frequency_handlers.py +++ b/tests/sentry/workflow_engine/handlers/condition/test_event_frequency_handlers.py @@ -9,6 +9,7 @@ EventFrequencyCountHandler, ) from sentry.workflow_engine.models.data_condition import Condition +from sentry.workflow_engine.types import WorkflowJob from tests.sentry.workflow_engine.handlers.condition.test_base import ( ConditionTestCase, EventFrequencyQueryTestBase, @@ -27,6 +28,10 @@ class TestEventFrequencyCountCondition(ConditionTestCase): "comparisonType": ComparisonType.COUNT, } + def setUp(self): + super().setUp() + self.job = WorkflowJob({"event": self.group_event}) + def test_count(self): dc = self.create_data_condition( type=self.condition, @@ -34,8 +39,11 @@ def test_count(self): condition_result=True, ) - self.assert_slow_cond_passes(dc, 1001) - self.assert_slow_cond_does_not_pass(dc, 999) + self.job["snuba_results"] = [1001] + self.assert_passes(dc, self.job) + + self.job["snuba_results"] = [999] + self.assert_does_not_pass(dc, self.job) def test_dual_write_count(self): dcg = self.create_data_condition_group() @@ -81,6 +89,10 @@ class TestEventFrequencyPercentCondition(ConditionTestCase): "comparisonType": ComparisonType.PERCENT, } + def setUp(self): + super().setUp() + self.job = WorkflowJob({"event": self.group_event}) + def test_percent(self): dc = self.create_data_condition( type=self.condition, @@ -92,8 +104,11 @@ def test_percent(self): condition_result=True, ) - self.assert_slow_cond_passes(dc, [21, 10]) - self.assert_slow_cond_does_not_pass(dc, [20, 10]) + self.job["snuba_results"] = [21, 10] + self.assert_passes(dc, self.job) + + self.job["snuba_results"] = [20, 10] + self.assert_does_not_pass(dc, self.job) def test_dual_write_percent(self): self.payload.update({"comparisonType": ComparisonType.PERCENT, "comparisonInterval": "1d"}) diff --git a/tests/sentry/workflow_engine/migration_helpers/test_migrate_alert_rule.py b/tests/sentry/workflow_engine/migration_helpers/test_migrate_alert_rule.py index 713bf1630b5701..bbe25954d9b83f 100644 --- a/tests/sentry/workflow_engine/migration_helpers/test_migrate_alert_rule.py +++ b/tests/sentry/workflow_engine/migration_helpers/test_migrate_alert_rule.py @@ -85,6 +85,62 @@ def assert_alert_rule_migrated(alert_rule, project_id): assert data_source_detector.detector == detector +def assert_alert_rule_resolve_trigger_migrated(alert_rule): + detector_trigger = DataCondition.objects.get( + comparison=alert_rule.resolve_threshold, + condition_result=DetectorPriorityLevel.OK, + type=( + Condition.LESS_OR_EQUAL + if alert_rule.threshold_type == AlertRuleThresholdType.ABOVE.value + else Condition.GREATER_OR_EQUAL + ), + ) + detector = AlertRuleDetector.objects.get(alert_rule=alert_rule).detector + + assert detector_trigger.type == Condition.LESS_OR_EQUAL + assert detector_trigger.condition_result == DetectorPriorityLevel.OK + assert detector_trigger.condition_group == detector.workflow_condition_group + + data_condition = DataCondition.objects.get(comparison=DetectorPriorityLevel.OK) + + assert data_condition.type == Condition.ISSUE_PRIORITY_EQUALS + assert data_condition.comparison == DetectorPriorityLevel.OK + assert data_condition.condition_result is True + assert WorkflowDataConditionGroup.objects.filter( + condition_group=data_condition.condition_group + ).exists() + + +def assert_alert_rule_trigger_migrated(alert_rule_trigger): + assert AlertRuleTriggerDataCondition.objects.filter( + alert_rule_trigger=alert_rule_trigger + ).exists() + + condition_result = ( + DetectorPriorityLevel.MEDIUM + if alert_rule_trigger.label == "warning" + else DetectorPriorityLevel.HIGH + ) + detector_trigger = DataCondition.objects.get( + comparison=alert_rule_trigger.alert_threshold, + condition_result=condition_result, + ) + + assert ( + detector_trigger.type == Condition.GREATER + if alert_rule_trigger.alert_rule.threshold_type == AlertRuleThresholdType.ABOVE.value + else Condition.LESS + ) + assert detector_trigger.condition_result == condition_result + + data_condition = DataCondition.objects.get(comparison=condition_result, condition_result=True) + assert data_condition.type == Condition.ISSUE_PRIORITY_EQUALS + assert data_condition.condition_result is True + assert WorkflowDataConditionGroup.objects.filter( + condition_group=data_condition.condition_group + ).exists() + + class AlertRuleMigrationHelpersTest(APITestCase): def setUp(self): METADATA = { @@ -206,77 +262,9 @@ def test_create_metric_alert_trigger(self): migrate_metric_data_conditions(self.alert_rule_trigger_critical) migrate_resolve_threshold_data_conditions(self.metric_alert) - assert ( - AlertRuleTriggerDataCondition.objects.filter( - alert_rule_trigger__in=[ - self.alert_rule_trigger_critical, - self.alert_rule_trigger_warning, - ] - ).count() - == 2 - ) - detector_triggers = DataCondition.objects.filter( - comparison__in=[ - self.alert_rule_trigger_warning.alert_threshold, - self.alert_rule_trigger_critical.alert_threshold, - self.metric_alert.resolve_threshold, - ] - ) - - assert len(detector_triggers) == 3 - detector = AlertRuleDetector.objects.get(alert_rule=self.metric_alert).detector - - warning_detector_trigger = detector_triggers[0] - critical_detector_trigger = detector_triggers[1] - resolve_detector_trigger = detector_triggers[2] - - assert warning_detector_trigger.type == Condition.GREATER - assert warning_detector_trigger.condition_result == DetectorPriorityLevel.MEDIUM - assert warning_detector_trigger.condition_group == detector.workflow_condition_group - - assert critical_detector_trigger.type == Condition.GREATER - assert critical_detector_trigger.condition_result == DetectorPriorityLevel.HIGH - assert critical_detector_trigger.condition_group == detector.workflow_condition_group - - assert resolve_detector_trigger.type == Condition.LESS_OR_EQUAL - assert resolve_detector_trigger.condition_result == DetectorPriorityLevel.OK - assert resolve_detector_trigger.condition_group == detector.workflow_condition_group - - data_conditions = DataCondition.objects.filter( - comparison__in=[ - DetectorPriorityLevel.MEDIUM, - DetectorPriorityLevel.HIGH, - DetectorPriorityLevel.OK, - ] - ) - assert len(data_conditions) == 3 - warning_data_condition = data_conditions[0] - critical_data_condition = data_conditions[1] - resolve_data_condition = data_conditions[2] - - assert warning_data_condition.type == Condition.ISSUE_PRIORITY_EQUALS - assert warning_data_condition.comparison == DetectorPriorityLevel.MEDIUM - assert warning_data_condition.condition_result is True - assert warning_data_condition.condition_group == warning_data_condition.condition_group - assert WorkflowDataConditionGroup.objects.filter( - condition_group=warning_data_condition.condition_group - ).exists() - - assert critical_data_condition.type == Condition.ISSUE_PRIORITY_EQUALS - assert critical_data_condition.comparison == DetectorPriorityLevel.HIGH - assert critical_data_condition.condition_result is True - assert critical_data_condition.condition_group == critical_data_condition.condition_group - assert WorkflowDataConditionGroup.objects.filter( - condition_group=critical_data_condition.condition_group - ).exists() - - assert resolve_data_condition.type == Condition.ISSUE_PRIORITY_EQUALS - assert resolve_data_condition.comparison == DetectorPriorityLevel.OK - assert resolve_data_condition.condition_result is True - assert resolve_data_condition.condition_group == resolve_data_condition.condition_group - assert WorkflowDataConditionGroup.objects.filter( - condition_group=resolve_data_condition.condition_group - ).exists() + assert_alert_rule_trigger_migrated(self.alert_rule_trigger_warning) + assert_alert_rule_trigger_migrated(self.alert_rule_trigger_critical) + assert_alert_rule_resolve_trigger_migrated(self.metric_alert) def test_calculate_resolve_threshold_critical_only(self): migrate_alert_rule(self.metric_alert, self.rpc_user) diff --git a/tests/sentry/workflow_engine/processors/test_workflow.py b/tests/sentry/workflow_engine/processors/test_workflow.py index 8a52a70630f5ce..480dcd0da84de1 100644 --- a/tests/sentry/workflow_engine/processors/test_workflow.py +++ b/tests/sentry/workflow_engine/processors/test_workflow.py @@ -1,13 +1,23 @@ +from datetime import timedelta from unittest import mock +from sentry import buffer from sentry.eventstream.base import GroupState from sentry.grouping.grouptype import ErrorGroupType +from sentry.testutils.helpers.datetime import before_now, freeze_time +from sentry.testutils.helpers.redis import mock_redis_buffer from sentry.workflow_engine.models import DataConditionGroup from sentry.workflow_engine.models.data_condition import Condition -from sentry.workflow_engine.processors.workflow import evaluate_workflow_triggers, process_workflows +from sentry.workflow_engine.processors.workflow import ( + WORKFLOW_ENGINE_BUFFER_LIST_KEY, + evaluate_workflow_triggers, + process_workflows, +) from sentry.workflow_engine.types import WorkflowJob from tests.sentry.workflow_engine.test_base import BaseWorkflowTest +FROZEN_TIME = before_now(days=1).replace(hour=1, minute=30, second=0, microsecond=0) + class TestProcessWorkflows(BaseWorkflowTest): def setUp(self): @@ -105,8 +115,8 @@ def test_no_workflow_trigger(self): assert not triggered_workflows def test_workflow_many_filters(self): - if self.workflow.when_condition_group is not None: - self.workflow.when_condition_group.logic_type = DataConditionGroup.Type.ALL + assert self.workflow.when_condition_group + self.workflow.when_condition_group.update(logic_type=DataConditionGroup.Type.ALL) self.create_data_condition( condition_group=self.workflow.when_condition_group, @@ -118,9 +128,9 @@ def test_workflow_many_filters(self): triggered_workflows = evaluate_workflow_triggers({self.workflow}, self.job) assert triggered_workflows == {self.workflow} - def test_workflow_filterd_out(self): - if self.workflow.when_condition_group is not None: - self.workflow.when_condition_group.logic_type = DataConditionGroup.Type.ALL + def test_workflow_filtered_out(self): + assert self.workflow.when_condition_group + self.workflow.when_condition_group.update(logic_type=DataConditionGroup.Type.ALL) self.create_data_condition( condition_group=self.workflow.when_condition_group, @@ -136,3 +146,97 @@ def test_many_workflows(self): triggered_workflows = evaluate_workflow_triggers({self.workflow, workflow_two}, self.job) assert triggered_workflows == {self.workflow, workflow_two} + + def test_skips_slow_conditions(self): + # triggers workflow if the logic_type is ANY and a condition is met + self.create_data_condition( + condition_group=self.workflow.when_condition_group, + type=Condition.EVENT_FREQUENCY_COUNT, + comparison={ + "interval": "1h", + "value": 100, + }, + condition_result=True, + ) + + triggered_workflows = evaluate_workflow_triggers({self.workflow}, self.job) + assert triggered_workflows == {self.workflow} + + +@freeze_time(FROZEN_TIME) +class TestEnqueueWorkflow(BaseWorkflowTest): + buffer_timestamp = (FROZEN_TIME + timedelta(seconds=1)).timestamp() + + def setUp(self): + ( + self.workflow, + self.detector, + self.detector_workflow, + self.workflow_triggers, + ) = self.create_detector_and_workflow() + + occurrence = self.build_occurrence(evidence_data={"detector_id": self.detector.id}) + self.group, self.event, self.group_event = self.create_group_event( + occurrence=occurrence, + ) + self.job = WorkflowJob({"event": self.group_event}) + self.create_workflow_action(self.workflow) + self.mock_redis_buffer = mock_redis_buffer() + self.mock_redis_buffer.__enter__() + + def tearDown(self): + self.mock_redis_buffer.__exit__(None, None, None) + + def test_enqueues_workflow_all_logic_type(self): + assert self.workflow.when_condition_group + self.workflow.when_condition_group.update(logic_type=DataConditionGroup.Type.ALL) + self.create_data_condition( + condition_group=self.workflow.when_condition_group, + type=Condition.EVENT_FREQUENCY_COUNT, + comparison={ + "interval": "1h", + "value": 100, + }, + condition_result=True, + ) + + triggered_workflows = evaluate_workflow_triggers({self.workflow}, self.job) + assert not triggered_workflows + + process_workflows(self.job) + + project_ids = buffer.backend.get_sorted_set( + WORKFLOW_ENGINE_BUFFER_LIST_KEY, 0, self.buffer_timestamp + ) + assert project_ids + assert project_ids[0][0] == self.project.id + + def test_enqueues_workflow_any_logic_type(self): + assert self.workflow.when_condition_group + self.workflow.when_condition_group.conditions.all().delete() + + self.create_data_condition( + condition_group=self.workflow.when_condition_group, + type=Condition.EVENT_FREQUENCY_COUNT, + comparison={ + "interval": "1h", + "value": 100, + }, + condition_result=True, + ) + self.create_data_condition( + condition_group=self.workflow.when_condition_group, + type=Condition.REGRESSION_EVENT, # fast condition, does not pass + comparison=True, + condition_result=True, + ) + + triggered_workflows = evaluate_workflow_triggers({self.workflow}, self.job) + assert not triggered_workflows + + process_workflows(self.job) + + project_ids = buffer.backend.get_sorted_set( + WORKFLOW_ENGINE_BUFFER_LIST_KEY, 0, self.buffer_timestamp + ) + assert project_ids[0][0] == self.project.id diff --git a/tests/sentry_plugins/redmine/__init__.py b/tests/sentry_plugins/redmine/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry_plugins/redmine/test_plugin.py b/tests/sentry_plugins/redmine/test_plugin.py new file mode 100644 index 00000000000000..76f7b686ae5db7 --- /dev/null +++ b/tests/sentry_plugins/redmine/test_plugin.py @@ -0,0 +1,45 @@ +from functools import cached_property + +import orjson +import responses +from django.urls import reverse + +from sentry.testutils.cases import PluginTestCase +from sentry_plugins.redmine.plugin import RedminePlugin + + +class RedminePluginTest(PluginTestCase): + @cached_property + def plugin(self): + return RedminePlugin() + + def test_conf_key(self): + assert self.plugin.conf_key == "redmine" + + def test_entry_point(self): + self.assertPluginInstalled("redmine", self.plugin) + self.assertAppInstalled("redmine", "sentry_plugins.redmine") + + @responses.activate + def test_config_validation(self): + responses.add(responses.GET, "https://bugs.redmine.org") + + config = { + "host": "https://bugs.redmine.org", + "key": "supersecret", + } + + self.plugin.validate_config(self.project, config) + + def test_no_secrets(self): + self.login_as(self.user) + self.plugin.set_option("key", "supersecret", self.project) + url = reverse( + "sentry-api-0-project-plugin-details", + args=[self.organization.slug, self.project.slug, "redmine"], + ) + res = self.client.get(url) + config = orjson.loads(res.content)["config"] + key_config = [item for item in config if item["name"] == "key"][0] + assert key_config.get("type") == "secret" + assert key_config.get("value") is None diff --git a/tests/sentry_plugins/sessionstack/test_plugin.py b/tests/sentry_plugins/sessionstack/test_plugin.py index 54038f62c5fa20..45d1e901971d3c 100644 --- a/tests/sentry_plugins/sessionstack/test_plugin.py +++ b/tests/sentry_plugins/sessionstack/test_plugin.py @@ -1,6 +1,8 @@ from functools import cached_property +import orjson import responses +from django.urls import reverse from sentry.testutils.cases import PluginTestCase from sentry_plugins.sessionstack.plugin import SessionStackPlugin @@ -75,3 +77,16 @@ def test_event_preprocessing(self): session_url = sessionstack_context.get("session_url") assert session_url == EXPECTED_SESSION_URL + + def test_no_secrets(self): + self.login_as(self.user) + self.plugin.set_option("api_token", "example-api-token", self.project) + url = reverse( + "sentry-api-0-project-plugin-details", + args=[self.organization.slug, self.project.slug, "sessionstack"], + ) + res = self.client.get(url) + config = orjson.loads(res.content)["config"] + api_token_config = [item for item in config if item["name"] == "api_token"][0] + assert api_token_config.get("type") == "secret" + assert api_token_config.get("value") is None diff --git a/webpack.config.ts b/webpack.config.ts index 9d3ecc59f7138e..26019d59543942 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -1,5 +1,3 @@ -/* eslint-env node */ - import {WebpackReactSourcemapsPlugin} from '@acemarke/react-prod-sourcemaps'; import {RsdoctorWebpackPlugin} from '@rsdoctor/webpack-plugin'; import {sentryWebpackPlugin} from '@sentry/webpack-plugin'; diff --git a/yarn.lock b/yarn.lock index dade0f4618f4c7..a3b2c6154a3982 100644 --- a/yarn.lock +++ b/yarn.lock @@ -345,7 +345,7 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== -"@babel/helper-validator-identifier@^7.25.9": +"@babel/helper-validator-identifier@^7.24.7", "@babel/helper-validator-identifier@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== @@ -3482,10 +3482,10 @@ resolved "https://registry.yarnpkg.com/@sentry/release-parser/-/release-parser-1.3.1.tgz#0ab8be23fd494d80dd0e4ec8ae5f3d13f805b13d" integrity sha512-/dGpCq+j3sJhqQ14RNEEL45Ot/rgq3jAlZDD/8ufeqq+W8p4gUhSrbGWCRL82NEIWY9SYwxYXGXjRcVPSHiA1Q== -"@sentry/status-page-list@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@sentry/status-page-list/-/status-page-list-0.3.0.tgz#d5520057007be1a021933aae26dfa6a4a3981c40" - integrity sha512-v/MkVOvs48QioXt7Ex8gmZEFGvjukWqx2DlIej+Ac4pVQJAfzF6/DFFVT3IK8/owIqv/IdEhY0XzHOcIB0yBIA== +"@sentry/status-page-list@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@sentry/status-page-list/-/status-page-list-0.6.0.tgz#9ae1a2cf0fa5a89f0eefd81e6887c13f15ef6270" + integrity sha512-umFIGPsFo8wjT5xLSraUd69GDJhBv8a8YgpAt8zE23SlkFNiWZcDP+Wr6yif2EQvB6Z6i3TmnQcz3mBN1j1kNA== "@sentry/types@8.48.0": version "8.48.0" @@ -4007,6 +4007,11 @@ dependencies: undici-types "~6.19.8" +"@types/normalize-package-data@^2.4.0": + version "2.4.4" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" + integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== + "@types/papaparse@^5.3.5": version "5.3.5" resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.5.tgz#e5ad94b1fe98e2a8ea0b03284b83d2cb252bbf39" @@ -5157,6 +5162,11 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +builtin-modules@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" + integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== + bundle-name@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" @@ -5309,6 +5319,11 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.0.tgz#b4ed1fb6818dea4803a55c623041f9165d2066b2" integrity sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw== +ci-info@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.1.0.tgz#92319d2fa29d2620180ea5afed31f589bc98cf83" + integrity sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A== + cjs-module-lexer@^1.0.0, cjs-module-lexer@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" @@ -5326,6 +5341,13 @@ clean-css@^5.2.2: dependencies: source-map "~0.6.0" +clean-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clean-regexp/-/clean-regexp-1.0.0.tgz#8df7c7aae51fd36874e8f8d05b9180bc11a3fed7" + integrity sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw== + dependencies: + escape-string-regexp "^1.0.5" + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -6725,6 +6747,28 @@ eslint-plugin-typescript-sort-keys@^3.3.0: json-schema "^0.4.0" natural-compare-lite "^1.4.0" +eslint-plugin-unicorn@^56.0.1: + version "56.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-56.0.1.tgz#d10a3df69ba885939075bdc95a65a0c872e940d4" + integrity sha512-FwVV0Uwf8XPfVnKSGpMg7NtlZh0G0gBarCaFcMUOoqPxXryxdYxTRRv4kH6B9TFCVIrjRXG+emcxIk2ayZilog== + dependencies: + "@babel/helper-validator-identifier" "^7.24.7" + "@eslint-community/eslint-utils" "^4.4.0" + ci-info "^4.0.0" + clean-regexp "^1.0.0" + core-js-compat "^3.38.1" + esquery "^1.6.0" + globals "^15.9.0" + indent-string "^4.0.0" + is-builtin-module "^3.2.1" + jsesc "^3.0.2" + pluralize "^8.0.0" + read-pkg-up "^7.0.1" + regexp-tree "^0.1.27" + regjsparser "^0.10.0" + semver "^7.6.3" + strip-indent "^3.0.0" + eslint-scope@5.1.1, eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" @@ -6805,7 +6849,7 @@ esprima@^4.0.0, esprima@^4.0.1: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.5.0: +esquery@^1.5.0, esquery@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== @@ -7412,7 +7456,7 @@ globals@^14.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globals@^15.14.0: +globals@^15.14.0, globals@^15.9.0: version "15.14.0" resolved "https://registry.yarnpkg.com/globals/-/globals-15.14.0.tgz#b8fd3a8941ff3b4d38f3319d433b61bbb482e73f" integrity sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig== @@ -7558,6 +7602,11 @@ hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: dependencies: react-is "^16.7.0" +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -7918,6 +7967,13 @@ is-boolean-object@^1.2.1: call-bound "^1.0.2" has-tostringtag "^1.0.2" +is-builtin-module@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169" + integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A== + dependencies: + builtin-modules "^3.3.0" + is-bun-module@^1.0.2: version "1.3.0" resolved "https://registry.yarnpkg.com/is-bun-module/-/is-bun-module-1.3.0.tgz#ea4d24fdebfcecc98e81bcbcb506827fee288760" @@ -8738,6 +8794,11 @@ jsesc@^3.0.2, jsesc@~3.0.2: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e" integrity sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g== +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== + json-buffer@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" @@ -9464,6 +9525,16 @@ nopt@^7.2.0: dependencies: abbrev "^2.0.0" +normalize-package-data@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -9908,6 +9979,11 @@ platformicons@^7.0.1: "@types/node" "*" "@types/react" "*" +pluralize@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" + integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== + po-catalog-loader@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/po-catalog-loader/-/po-catalog-loader-2.1.0.tgz#3e64ab44b4dedf96b9276bcbdf39848f8a5bf2e6" @@ -10511,10 +10587,10 @@ react-popper@^2.3.0: react-fast-compare "^3.0.1" warning "^4.0.2" -react-refresh@0.14.0: - version "0.14.0" - resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" - integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== +react-refresh@0.16.0: + version "0.16.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.16.0.tgz#e7d45625f05c9709466d09348a25d22f79b2ad23" + integrity sha512-FPvF2XxTSikpJxcr+bHut2H4gJ17+18Uy20D5/F+SKzFap62R3cM5wH6b8WN3LyGSYeQilLEcJcR1fjBSI2S1A== react-resizable@^3.0.4: version "3.0.4" @@ -10597,6 +10673,25 @@ react@18.2.0: dependencies: loose-envify "^1.1.0" +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + readable-stream@^2.0.1: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" @@ -10694,6 +10789,11 @@ regenerator-transform@^0.15.2: dependencies: "@babel/runtime" "^7.8.4" +regexp-tree@^0.1.27: + version "0.1.27" + resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.27.tgz#2198f0ef54518ffa743fe74d983b56ffd631b6cd" + integrity sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA== + regexp.prototype.flags@^1.5.3: version "1.5.3" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz#b3ae40b1d2499b8350ab2c3fe6ef3845d3a96f42" @@ -10721,6 +10821,13 @@ regjsgen@^0.8.0: resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.8.0.tgz#df23ff26e0c5b300a6470cad160a9d090c3a37ab" integrity sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q== +regjsparser@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.10.0.tgz#b1ed26051736b436f22fdec1c8f72635f9f44892" + integrity sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA== + dependencies: + jsesc "~0.5.0" + regjsparser@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.11.1.tgz#ae55c74f646db0c8fcb922d4da635e33da405149" @@ -10800,6 +10907,15 @@ resolve.exports@^2.0.0: resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.0.tgz#c1a0028c2d166ec2fbf7d0644584927e76e7400e" integrity sha512-6K/gDlqgQscOlg9fSRpWstA8sYe8rbELsSTNpx+3kTrsVCzvSl0zIvRErM7fdl9ERWDsKnrLnwB+Ne89918XOg== +resolve@^1.10.0, resolve@^1.22.4: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.1: version "1.22.8" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" @@ -10809,15 +10925,6 @@ resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.1: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^1.22.4: - version "1.22.10" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" - integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== - dependencies: - is-core-module "^2.16.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - resolve@^2.0.0-next.5: version "2.0.0-next.5" resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.5.tgz#6b0ec3107e671e52b68cd068ef327173b90dc03c" @@ -10950,16 +11057,16 @@ selfsigned@^2.4.1: "@types/node-forge" "^1.3.0" node-forge "^1" +"semver@2 || 3 || 4 || 5", semver@^5.6.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + semver@7.6.3, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== -semver@^5.6.0: - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" @@ -11264,6 +11371,32 @@ source-map@^0.7.3, source-map@^0.7.4: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== +spdx-correct@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c" + integrity sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz#5d607d27fc806f66d7b64a766650fa890f04ed66" + integrity sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w== + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.21" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz#6d6e980c9df2b6fc905343a3b2d702a6239536c3" + integrity sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg== + spdy-transport@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" @@ -11828,6 +11961,11 @@ type-detect@^4.0.0: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + type-fest@^0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" @@ -11926,9 +12064,9 @@ undici-types@~6.19.8: integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== undici@^5.25.4: - version "5.28.4" - resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068" - integrity sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g== + version "5.28.5" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.5.tgz#b2b94b6bf8f1d919bc5a6f31f2c01deb02e54d4b" + integrity sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA== dependencies: "@fastify/busboy" "^2.0.0" @@ -12086,6 +12224,14 @@ v8-to-istanbul@^9.0.1: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"