From fc9ccb0f4754939a4cf19bfdb08b526f5a491f5a Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 17 Jan 2025 16:13:03 -0800 Subject: [PATCH 01/74] ref: Convert ContextPickerModal to an FC (#83625) Test Notes: This modal is behind Settings Search, the cmd+K search, and Help Search in the sidebar. Search works as expected with one special case I discovered: Suggestions may include the `configUrl` field. In this case clicking on the suggestion will trigger that url to load and be rendered as a 2nd-level search list. To see this in action, go to Org Settings and search for something like "Github". One of the results will be the GitHub Integration, where you can connect to a github org. Clicking on that result will open the 2nd screen with a list of available github orgs to choose. | First search | list from `configUrl` | | --- | --- | | SCR-20250117-iogq | SCR-20250117-iolm Also before, if there was nothing returned from the `configUrl` url, only an empty array for example, we'd see an empty modal: SCR-20250117-jdyq This PR adds a call to `sharedProps.onFinish(sharedProps.nextPath);` which will redirect the user to the root of the config page instead of showing the blank modal. In my testing the root is something like `/settings/integrations/github/`. When the modal is not blank, and there is a list of 2nd-level things to pick from, the url would've been constructed as ` `"/settings/integrations/github/" + pickedItem.value + "/"`. So redirecting just into the root is a little bit of a guess and might break in some cases. --------- Co-authored-by: Scott Cooper Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- static/app/components/contextPickerModal.tsx | 171 +++++++++---------- 1 file changed, 84 insertions(+), 87 deletions(-) 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)}; `; From 9543dd7cac460e6abf3ac92e56e50d40af42858a Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 17 Jan 2025 17:58:54 -0800 Subject: [PATCH 02/74] chore: Enable a few eslint rules that overlap with biomes rule list (#83693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Looking at our biome config here we can see all the linty rules that are enabled for JavaScript files: https://github.com/getsentry/sentry/blob/fc9ccb0f4754939a4cf19bfdb08b526f5a491f5a/biome.json#L14-L87 The docs provide the name of the equiv rule inside eslint core, or inside other eslint plugins if available. I mapped the names of the biome rules to those eslint rules here (below). Then I went through each rule and enabled it within the codebase. It was meant to be trivial because biome should've prevented violations, but a few places did need to be fixed up. Anywhere that had a biome-ignore statement needed an eslint one too. There are still a few rules that biome implements which are not in our eslint config right now. The categories are broadly: 1. Rules from plugins that i haven't installed yet, like from `unicorn/*` or from `barrel-files/*`. I'll import these plugins in a followup and check things off the list. 2. Some rules have no eslint equiv: `noApproximativeNumericConstant` & `noMisrefactoredShorthandAssign`, they're also not important to have enabled imo 3. Some rules have an eslint equiv but are also not important: `noShoutyConstants` & `noInvalidUseBeforeDeclaration` would cause lots of churn in the repo for example. Others are repalced by prettier. 4. Some rules depend on type annotations, these are challenging because they are often nice to have but type annotations are _slow_ in eslint. It would make pre-commit checks really terrible if that was enabled. `consistent-type-imports` is an example. I intend to get plugins to fill these specific biome gaps while maintaining pre-commit perf. ---- The rules table is here: | Biome Rule | ESLint Rule | Handled by | | ------------------------------------- | ------------------------------------------------- | ------------------------ | | noBlankTarget | react/jsx-no-target-blank | ✅ react/recommended | noGlobalObjectCalls | no-obj-calls | ✅ tsc | noUnreachable | no-unreachable | ✅ tsc | useIsNan | use-isnan | ✅ eslint/recommended | noUnusedPrivateClassMembers | no-unused-private-class-members | ✅ eslint/recommended | noInvalidUseBeforeDeclaration | @typescript-eslint/no-use-before-define | ✅ Disabled in sentry | noNodejsModules | import/no-nodejs-modules | ✅ eslint.config (except scripts) | useFlatMap | unicorn/prefer-array-flat-map | TODO | useOptionalChain | @typescript-eslint/prefer-optional-chain | TODO typescript/stylistic-type-checked | noEmptyTypeParameters | Prettier? | TODO | noUselessLoneBlockStatements | no-lone-blocks | ✅ eslint.config | noUselessEmptyExport | @typescript-eslint/no-useless-empty-export | ✅ eslint.config | noUselessConstructor | @typescript-eslint/no-useless-constructor | ✅ typescript/strict | noUselessTypeConstraint | @typescript-eslint/no-unnecessary-type-constraint | ✅ typescript/recommended | noExcessiveNestedTestSuites | jest/max-nested-describe (max=5) | ✅ eslint.config | noBarrelFile | barrel-files/avoid-barrel-files | TODO | noDangerouslySetInnerHtmlWithChildren | react/no-danger-with-children | ✅ react/recommended | noDebugger | no-debugger | ✅ eslint/recommended | noDoubleEquals | eqeqeq | ✅ eslint.config | noDuplicateJsxProps | react/jsx-no-duplicate-props | ✅ react/recommended | noDuplicateObjectKeys | no-dupe-keys | ✅ tsc | noDuplicateParameters | no-dupe-args | ✅ tsc | noDuplicateCase | no-duplicate-case | ✅ eslint/recommended | noFallthroughSwitchClause | no-fallthrough | ✅ eslint/recommended | noRedeclare | @typescript-eslint/no-redeclare | ✅ tsc | noSparseArray | no-sparse-arrays | ✅ eslint recommended | noUnsafeDeclarationMerging | @typescript-eslint/no-unsafe-declaration-merging | ✅ typescript/recommended | noUnsafeNegation | no-unsafe-negation | ✅ tsc | useIsArray | unicorn/no-instanceof-array | TODO | noApproximativeNumericConstant | approx_constant | TODO | noMisrefactoredShorthandAssign | misrefactored_assign_op | TODO | useAwait | require-await & @typescript-eslint/require-await | ✅ esint.config | useNamespaceKeyword | @typescript-eslint/prefer-namespace-keyword | ✅ typescript/recommended | noFocusedTests | jest/no-focused-tests | ✅ jest/recommended | noDuplicateTestHooks | jest/no-duplicate-hooks | ✅ eslint.config | noCommaOperator | no-sequences | ✅ eslint.config | noShoutyConstants | ??? | TODO | noParameterProperties | @typescript-eslint/parameter-properties | ✅ typescript | noVar | no-var | ✅ eslint.config | useConst | prefer-const | ✅ tsc | useShorthandFunctionType | @typescript-eslint/prefer-function-type | ✅ typescript/stylistic | useExportType | @typescript-eslint/consistent-type-exports | TODO requires types | useImportType | @typescript-eslint/consistent-type-imports | TODO requires types | useNodejsImportProtocol | unicorn/prefer-node-protocol | TODO | useLiteralEnumMembers | @typescript-eslint/prefer-literal-enum-member | ✅ typescript/strict | useEnumInitializers | @typescript-eslint/prefer-enum-initializers | ✅ | useAsConstAssertion | @typescript-eslint/prefer-as-const | ✅ typescript/recommended | useBlockStatements | curly | ✅ prettier New plugins: - https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/prefer-array-flat-map.md - https://github.com/thepassle/eslint-plugin-barrel-files/blob/main/docs/rules/avoid-barrel-files.md - https://tanstack.com/query/v5/docs/eslint/eslint-plugin-query --- eslint.config.mjs | 31 +++++++------------ package.json | 2 +- .../events/autofix/autofixMessageBox.tsx | 2 +- .../profiling/renderers/UIFramesRenderer.tsx | 14 ++++----- .../renderers/flamegraphRenderer.tsx | 14 ++++----- static/app/utils/statics-setup.tsx | 2 +- .../issue/setupMessagingIntegrationButton.tsx | 2 +- .../views/insights/mobile/screens/utils.ts | 2 +- static/app/views/issueList/utils.tsx | 2 +- tests/js/setup.ts | 2 ++ 10 files changed, 32 insertions(+), 41 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1d8f3b8ca4506e..1b341026c09c80 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -236,6 +236,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 +269,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 +279,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 @@ -369,6 +370,7 @@ export default typescript.config([ {selector: 'typeLike', format: ['PascalCase'], leadingUnderscore: 'allow'}, {selector: 'enumMember', format: ['UPPER_CASE']}, ], + '@typescript-eslint/no-restricted-types': [ 'error', { @@ -392,6 +394,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 +409,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 +430,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 +441,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': [ @@ -558,26 +559,18 @@ export default typescript.config([ // 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 }, }, { diff --git a/package.json b/package.json index edbda0fe68329b..8d650e48337788 100644 --- a/package.json +++ b/package.json @@ -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", 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/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/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/insights/mobile/screens/utils.ts b/static/app/views/insights/mobile/screens/utils.ts index 0e7d02535019ce..a75706ea4f0203 100644 --- a/static/app/views/insights/mobile/screens/utils.ts +++ b/static/app/views/insights/mobile/screens/utils.ts @@ -5,7 +5,7 @@ import {formatPercentage} from 'sentry/utils/number/formatPercentage'; import {VitalState} from 'sentry/views/performance/vitalDetail/utils'; const formatMetricValue = (metric: MetricValue, field?: string | undefined): string => { - 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/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/tests/js/setup.ts b/tests/js/setup.ts index c75a70621b8602..737529f4233d36 100644 --- a/tests/js/setup.ts +++ b/tests/js/setup.ts @@ -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; } From 6892c034a075b451e65c4cc463647479f21fc335 Mon Sep 17 00:00:00 2001 From: Sebastian Zivota Date: Mon, 20 Jan 2025 09:40:26 +0100 Subject: [PATCH 03/74] fix(js): Recognize more paths as not-builtin for node (#83631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR that originally introduced the logic for skipping builtin Node frames was https://github.com/getsentry/sentry/pull/49174. Eventually we added Windows paths (`X:\…`). We have now seen customer events with `abs_path`s starting with `http(s)`, so we consider those non-builtin as well. --- src/sentry/lang/javascript/processing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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): From ffe2e436c29dc30c85d4b299d1e380ceeda8394b Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jan 2025 10:35:31 +0100 Subject: [PATCH 04/74] fix(sign-in): Fix redirect correct org after accepting invite (#82005) --- .../services/organization/model.py | 1 + .../services/organization/serial.py | 1 + src/sentry/web/frontend/auth_login.py | 19 +++ .../test_accept_organization_invite.py | 128 ++++++++++++++++++ .../frontend/test_auth_organization_login.py | 12 +- 5 files changed, 155 insertions(+), 6 deletions(-) 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/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/tests/acceptance/test_accept_organization_invite.py b/tests/acceptance/test_accept_organization_invite.py index 80bda384569d7e..fcaba231d04645 100644 --- a/tests/acceptance/test_accept_organization_invite.py +++ b/tests/acceptance/test_accept_organization_invite.py @@ -1,4 +1,6 @@ from django.db.models import F +from django.test import override_settings +from selenium.webdriver.common.by import By from sentry.models.authprovider import AuthProvider from sentry.models.organization import Organization @@ -23,6 +25,14 @@ def setUp(self): teams=[self.team], ) + def _sign_in_user(self, email, password): + """ + Helper method to sign in a user with given email and password. + """ + self.browser.find_element(By.ID, "id_username").send_keys(email) + self.browser.find_element(By.ID, "id_password").send_keys(password) + self.browser.find_element(By.XPATH, "//button[contains(text(), 'Sign In')]").click() + def test_invite_simple(self): self.login_as(self.user) self.browser.get(self.member.get_invite_link().split("/", 3)[-1]) @@ -52,3 +62,121 @@ def test_invite_sso_org(self): self.browser.wait_until('[data-test-id="accept-invite"]') assert self.browser.element_exists_by_test_id("action-info-sso") assert self.browser.element_exists('[data-test-id="sso-login"]') + + @override_settings(SENTRY_SINGLE_ORGANIZATION=True) + def test_authenticated_user_already_member_of_an_org_accept_invite_other_org(self): + """ + Test that an authenticated user already part of an organization can accept an invite to another organization. + """ + + # Setup: Create a second user and make them a member of an organization + email = "dummy@example.com" + password = "dummy" + user2 = self.create_user(email=email) + user2.set_password(password) + user2.save() + self.create_organization(name="Second Org", owner=user2) + + # Action: Invite User2 to the first organization + new_member = self.create_member( + user=None, + email=user2.email, + organization=self.org, + role="owner", + teams=[self.team], + ) + + self.login_as(user2) + + # Simulate the user accessing the invite link + self.browser.get(new_member.get_invite_link().split("/", 3)[-1]) + self.browser.wait_until('[data-test-id="accept-invite"]') + + self.browser.click('button[data-test-id="join-organization"]') + assert self.browser.wait_until('[aria-label="Create project"]') + + @override_settings(SENTRY_SINGLE_ORGANIZATION=True) + def test_not_authenticated_user_already_member_of_an_org_accept_invite_other_org(self): + """ + Test that a not authenticated user already part of an organization can accept an invite to another organization. + """ + + # Setup: Create a second user and make them a member of an organization + email = "dummy@example.com" + password = "dummy" + user2 = self.create_user(email=email) + user2.set_password(password) + user2.save() + self.create_organization(name="Second Org", owner=user2) + + # Action: Invite User2 to the first organization + new_member = self.create_member( + user=None, + email=user2.email, + organization=self.org, + role="member", + teams=[self.team], + ) + + # Simulate the user accessing the invite link + self.browser.get(new_member.get_invite_link().split("/", 3)[-1]) + self.browser.wait_until('[data-test-id="accept-invite"]') + + # Choose to login with existing account + self.browser.click('a[data-test-id="link-with-existing"]') + self.browser.wait_until_not('[data-test-id="loading-indicator"]') + + # Handle form validation: Prevent default invalid event blocking + self.browser.driver.execute_script( + "document.addEventListener('invalid', function(e) { e.preventDefault(); }, true);" + ) + + # Login + self._sign_in_user(email, password) + self.browser.wait_until('[data-test-id="join-organization"]') + + # Display the acceptance view for the invitation to join a new organization + assert self.browser.element_exists(f"[aria-label='Join the {self.org.slug} organization']") + + @override_settings(SENTRY_SINGLE_ORGANIZATION=True) + def test_existing_user_invite_2fa_enforced_org(self): + """ + Test that a user who has an existing Sentry account can accept an invite to another organization + and is required to go through the 2FA configuration view. + """ + self.org.update(flags=F("flags").bitor(Organization.flags.require_2fa)) + # Setup: Create a second user and make them a member of an organization + email = "dummy@example.com" + password = "dummy" + user2 = self.create_user(email=email) + user2.set_password(password) + user2.save() + self.create_organization(name="Second Org", owner=user2) + + # Action: Invite User2 to the first organization + new_member = self.create_member( + user=None, + email=user2.email, + organization=self.org, + role="owner", + teams=[self.team], + ) + # Simulate the user accessing the invite link + self.browser.get(new_member.get_invite_link().split("/", 3)[-1]) + self.browser.wait_until('[data-test-id="accept-invite"]') + + # Accept the invitation using the existing account + self.browser.click('a[data-test-id="link-with-existing"]') + self.browser.wait_until_not('[data-test-id="loading-indicator"]') + + # Handle form validation: Prevent default invalid event blocking + self.browser.driver.execute_script( + "document.addEventListener('invalid', function(e) { e.preventDefault(); }, true);" + ) + + # Login using existing credentials + self._sign_in_user(email, password) + self.browser.wait_until('[data-test-id="2fa-warning"]') + + # Display the 2FA configuration view + assert self.browser.element_exists("[aria-label='Configure Two-Factor Auth']") 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}) From a797cc0fac8d5a191ab08bfecf159653786e0679 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jan 2025 10:42:29 +0100 Subject: [PATCH 05/74] ref(quick-start): Remove 'ISSUE_TRACKER' from onboarding tasks (#83493) --- .../models/organizationonboardingtask.py | 6 -- src/sentry/plugins/bases/issue.py | 15 ++-- src/sentry/plugins/bases/issue2.py | 12 ++- src/sentry/receivers/features.py | 7 ++ src/sentry/receivers/onboarding.py | 73 ------------------- src/sentry/signals.py | 1 - tests/sentry/receivers/test_onboarding.py | 30 -------- 7 files changed, 25 insertions(+), 119 deletions(-) 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/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/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/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, From b7a8e26225bceb813b0932f4f7b9cc6ac18cd751 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jan 2025 11:04:38 +0100 Subject: [PATCH 06/74] ref(onboarding): Introduce 'Store Minidumps As Attachments' field to onboarding docs of 'unity', 'minidump' and 'unreal' (#83642) --- .../storeCrashReportsConfig.tsx | 74 +++++++++++++++++++ .../forms/projectSecurityAndPrivacyGroups.tsx | 8 ++ .../minidump/minidump.spec.tsx | 11 +++ .../gettingStartedDocs/minidump/minidump.tsx | 13 +++- .../gettingStartedDocs/unity/unity.spec.tsx | 11 +++ static/app/gettingStartedDocs/unity/unity.tsx | 12 ++- .../gettingStartedDocs/unreal/unreal.spec.tsx | 11 +++ .../app/gettingStartedDocs/unreal/unreal.tsx | 10 +++ 8 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 static/app/components/onboarding/gettingStartedDoc/storeCrashReportsConfig.tsx 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/data/forms/projectSecurityAndPrivacyGroups.tsx b/static/app/data/forms/projectSecurityAndPrivacyGroups.tsx index 452676a38f3256..087b84a70adcc6 100644 --- a/static/app/data/forms/projectSecurityAndPrivacyGroups.tsx +++ b/static/app/data/forms/projectSecurityAndPrivacyGroups.tsx @@ -43,9 +43,11 @@ function hasProjectWriteAndOrgOverride({ function projectWriteAndOrgOverrideDisabledReason({ organization, name, + project, }: { name: string; organization: Organization; + project: Project; }) { if (hasOrgOverride({organization, name})) { return t( @@ -53,6 +55,10 @@ function projectWriteAndOrgOverrideDisabledReason({ ); } + if (!hasEveryAccess(['project:write'], {organization, project})) { + return t("You do not have permission to modify this project's setting."); + } + return null; } @@ -61,6 +67,8 @@ const formGroups: JsonFormObject[] = [ title: t('Security & Privacy'), fields: [ { + disabled: hasProjectWriteAndOrgOverride, + disabledReason: projectWriteAndOrgOverrideDisabledReason, name: 'storeCrashReports', type: 'select', label: t('Store Minidumps As Attachments'), 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: ( + + ), + }, ], }; From f346011f0c5554e2e37509aa3c0a3d54ddbb9593 Mon Sep 17 00:00:00 2001 From: Alexander Tarasov Date: Mon, 20 Jan 2025 11:58:04 +0100 Subject: [PATCH 07/74] fix(integrations): hide config values from `redmine` and `sessionstack` plugin details (#83630) --- src/sentry_plugins/redmine/plugin.py | 19 ++++---- src/sentry_plugins/sessionstack/plugin.py | 17 ++++--- tests/sentry_plugins/redmine/__init__.py | 0 tests/sentry_plugins/redmine/test_plugin.py | 45 +++++++++++++++++++ .../sessionstack/test_plugin.py | 15 +++++++ 5 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 tests/sentry_plugins/redmine/__init__.py create mode 100644 tests/sentry_plugins/redmine/test_plugin.py 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/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 From 04f63f1b4e947f25c8fb65f0275671c6d32af0e5 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Mon, 20 Jan 2025 08:14:52 -0500 Subject: [PATCH 08/74] feat(dashboards-eap): Add extrapolation message to viewer (#83658) Adds `confidence` as a widget viewer context prop. When the data is fetched in `spanWidgetQueries` we call `onDataFetched` with the confidence. I updated the `onDataFetched` to overwrite keys instead of the whole object so I can add the new `confidence` key. That's set inside `data` in the widget card. Then, when the expand icon gets clicked, that data gets passed down to a call that sets the widget viewer context. That widget viewer context is used in `detail.tsx` to supply a `confidence` value to the `openWidgetViewerModal` function --- static/app/components/modals/widgetViewerModal.tsx | 7 ++++++- static/app/views/dashboards/detail.tsx | 11 +++++++++-- .../dashboards/widgetCard/genericWidgetQueries.tsx | 1 + static/app/views/dashboards/widgetCard/index.tsx | 8 +++++--- .../dashboards/widgetCard/spansWidgetQueries.tsx | 11 +++++++---- .../dashboards/widgetCard/widgetCardDataLoader.tsx | 1 + .../dashboards/widgetViewer/widgetViewerContext.tsx | 3 +++ 7 files changed, 32 insertions(+), 10 deletions(-) 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/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/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; From ea77199791a0cbd2caee764602a49967d071ebce Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Mon, 20 Jan 2025 08:36:05 -0500 Subject: [PATCH 09/74] fix(widget-builder): Clear sorting on field and y-axis changes (#83679) Sorting isn't necessary when you don't have a grouping, so update the state to set a default when a grouping is added (with priority for the yAxis if it exists) and clear the sort if a yAxis changes and a grouping is not applied. --- .../hooks/useWidgetBuilderState.spec.tsx | 60 +++++++++++++++++++ .../hooks/useWidgetBuilderState.tsx | 21 +++++++ 2 files changed, 81 insertions(+) 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); From abbeccb29c8ab3687f8aa8782267c5b5502d304a Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Mon, 20 Jan 2025 09:38:41 -0500 Subject: [PATCH 10/74] ref(dashboards): Simplify `WidgetCardChart` (#83535) Moving some code around, in preparation to integrate standards widgets. - Move error handling higher up. Instead of having a bunch of error panels everywhere, check once at the top of the `render` method - Move series colorization higher in the component. Moves the code up to reduce nesting, and makes it easier to return early if needed - Resolve duplication of JSX. Instead of a conditional, pass `enabled` to `ReleaseQueries` to prevent unnecessarily loading data --- .../app/views/dashboards/widgetCard/chart.tsx | 158 ++++++------------ 1 file changed, 50 insertions(+), 108 deletions(-) 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 && ( - - )} - - ); }} From 69f87daabd8212a12f54efad35264361326a400a Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Mon, 20 Jan 2025 16:10:30 +0100 Subject: [PATCH 11/74] ref(aws-lambda-integration): Ensure correct node options for all layers (#83633) Previously, we only had one node lambda layer called `SentryNodeServerlessSDK`. With the `v8` major of the sentry-javascript SDKs a new layer for `v7` was published under `SentryNodeServerlessSDKv7` while the `v8` layer rolled over into `SentryNodeServerlessSDK`. With `v8` the `@sentry/serverless` package was removed (see: #70137) and a version check was added to the integration to update `NODE_OPTIONS` for the integration accordingly. The version check assumes the `SentryNodeServerlessSDK` name and only checks the ARN version which is not enough to identify a `v7` layer since the introduction of `SentryNodeServerlessSDKv7`. With `v9` we will stop updating the `SentryNodeServerlessSDK` and instead publish `SentryNodeServerlessSDKv9` exclusively, the integration has to ensure the correct `NODE_OPTIONS` are set depending on layer name and ARN version which this PR adds. Closes: #82646 --- src/sentry/integrations/aws_lambda/utils.py | 54 ++++--- ...zation_integration_serverless_functions.py | 4 +- .../aws_lambda/test_integration.py | 146 +++++++++++------- 3 files changed, 125 insertions(+), 79 deletions(-) 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/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") From 3932ce06aba4e90618986c1c9d6f41ffda9f1f3f Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Mon, 20 Jan 2025 10:26:05 -0500 Subject: [PATCH 12/74] Revert "Revert "chore(auto_source_code): Drop old queue (#83640)"" (#83667) This reverts commit fae6fd34de212c67b311cd64704f5f39aea7f36d which got reverted in #83640. It was reverted because I made the mistake of removing the option before this was fully out. --- src/sentry/conf/server.py | 2 - src/sentry/options/defaults.py | 1 - src/sentry/tasks/auto_source_code_config.py | 11 -- src/sentry/tasks/post_process.py | 9 +- .../tasks/test_auto_source_code_config.py | 108 ++++++++--------- tests/sentry/tasks/test_post_process.py | 109 +++--------------- 6 files changed, 73 insertions(+), 167 deletions(-) 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/options/defaults.py b/src/sentry/options/defaults.py index 8143f4054a7c99..ca1d4f4cebf525 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. diff --git a/src/sentry/tasks/auto_source_code_config.py b/src/sentry/tasks/auto_source_code_config.py index 02c60c94a10e42..30a9a0a75e87da 100644 --- a/src/sentry/tasks/auto_source_code_config.py +++ b/src/sentry/tasks/auto_source_code_config.py @@ -79,17 +79,6 @@ 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", diff --git a/src/sentry/tasks/post_process.py b/src/sentry/tasks/post_process.py index 984a32a1dbaf2c..60f50fb5ac8ea9 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) 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/tests/sentry/tasks/test_auto_source_code_config.py b/tests/sentry/tasks/test_auto_source_code_config.py index 77adea10c382c2..ca32069624c7ae 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,7 @@ 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, event_id=self.event.event_id) is None assert_halt_metric(mock_record, error) @patch("sentry.tasks.auto_source_code_config.logger") @@ -70,7 +70,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, event_id=self.event.event_id) assert mock_logger.error.call_count == 1 assert_halt_metric(mock_record, ApiError("foo")) @@ -80,7 +80,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, event_id=self.event.event_id) assert not RepositoryProjectPathConfig.objects.exists() assert_failure_metric(mock_record, error) @@ -90,7 +90,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, event_id=self.event.event_id) assert_failure_metric(mock_record, DeriveCodeMappingsErrorReason.UNEXPECTED_ERROR) @@ -119,7 +119,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "\\" assert code_mapping.source_root == "" @@ -134,7 +134,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "C:sentry\\" assert code_mapping.source_root == "sentry/" @@ -149,7 +149,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "C:sentry\\" assert code_mapping.source_root == "src/sentry/" @@ -164,7 +164,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "D:\\Users\\code\\" assert code_mapping.source_root == "" @@ -223,7 +223,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 +234,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] # ./app/foo.tsx -> app/foo.tsx -> static/app/foo.tsx assert code_mapping.stack_root == "./" @@ -242,7 +242,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 +253,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] # ./app/foo.tsx -> app/foo.tsx -> app/foo.tsx assert code_mapping.stack_root == "./" @@ -261,7 +261,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 +269,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] # some/path/Test.tsx -> Test.tsx -> some/path/Test.tsx assert code_mapping.stack_root == "" @@ -277,7 +277,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 +285,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, event_id=self.event.event_id) assert not RepositoryProjectPathConfig.objects.exists() @@ -311,7 +311,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 +319,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, event_id=self.event.event_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 +334,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "" assert code_mapping.source_root == "" @@ -371,7 +371,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 +379,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, event_id=self.event.event_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 +393,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, event_id=self.event.event_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 +408,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, event_id=self.event.event_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 +425,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "app:///../" assert code_mapping.source_root == "" @@ -456,7 +456,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 +464,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, event_id=self.event.event_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 +479,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, event_id=self.event.event_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 +494,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, event_id=self.event.event_id) assert not RepositoryProjectPathConfig.objects.exists() @@ -515,7 +515,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 +523,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, event_id=self.event.event_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 +538,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, event_id=self.event.event_id) code_mapping = RepositoryProjectPathConfig.objects.all()[0] assert code_mapping.stack_root == "/sentry/" assert code_mapping.source_root == "src/sentry/" @@ -570,7 +570,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 +578,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, event_id=self.event.event_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 +593,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, event_id=self.event.event_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 +608,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, event_id=self.event.event_id) assert not RepositoryProjectPathConfig.objects.exists() @@ -650,7 +650,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 +662,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) assert mock_identify_stacktraces.call_count == 1 assert mock_get_trees_for_org.call_count == 1 @@ -672,7 +672,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) 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 +687,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 +718,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) assert mock_identify_stacktraces.call_count == 1 assert mock_get_trees_for_org.call_count == 1 @@ -728,7 +728,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 +736,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) 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 +751,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) 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") From 3c67c9822872b5e2fa64d3cbbd1d24b7072da312 Mon Sep 17 00:00:00 2001 From: "Armen Zambrano G." <44410+armenzg@users.noreply.github.com> Date: Mon, 20 Jan 2025 10:57:17 -0500 Subject: [PATCH 13/74] fix(auto_source_config): Pass group_id to avoid Snuba call (#83650) In #83475 we started passing the `event_id` instead of the Node data to prevent passing pickled data. Unfortunately, that causes an extra Snuba call (which can fail) to determine the group ID. If we pass the group_id when scheduling the task we won't need to call Snuba. Fixes [SENTRY-3MGG](https://sentry.sentry.io/issues/6222497528/) --- src/sentry/tasks/auto_source_code_config.py | 28 +++++--- src/sentry/tasks/post_process.py | 2 +- .../tasks/test_auto_source_code_config.py | 65 ++++++++++--------- 3 files changed, 54 insertions(+), 41 deletions(-) diff --git a/src/sentry/tasks/auto_source_code_config.py b/src/sentry/tasks/auto_source_code_config.py index 30a9a0a75e87da..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 @@ -85,7 +85,9 @@ def process_error(error: ApiError, extra: dict[str, str]) -> None: 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. @@ -93,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 @@ -133,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 60f50fb5ac8ea9..3b97a990f1742c 100644 --- a/src/sentry/tasks/post_process.py +++ b/src/sentry/tasks/post_process.py @@ -1017,7 +1017,7 @@ def process_code_mappings(job: PostProcessJob) -> None: else: return - auto_source_code_config.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("Failed to process automatic source code config") diff --git a/tests/sentry/tasks/test_auto_source_code_config.py b/tests/sentry/tasks/test_auto_source_code_config.py index ca32069624c7ae..c41c48482df9a0 100644 --- a/tests/sentry/tasks/test_auto_source_code_config.py +++ b/tests/sentry/tasks/test_auto_source_code_config.py @@ -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 auto_source_code_config(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"), ): - auto_source_code_config(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(), ): - auto_source_code_config(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"), ): - auto_source_code_config(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"]) } - auto_source_code_config(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"]) } - auto_source_code_config(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"]) } - auto_source_code_config(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"]) } - auto_source_code_config(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 == "" @@ -234,7 +237,7 @@ def test_auto_source_code_config_starts_with_period_slash(self): ["static/app/utils/handleXhrErrorResponse.tsx"], ) } - auto_source_code_config(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 == "./" @@ -253,7 +256,7 @@ def test_auto_source_code_config_starts_with_period_slash_no_containing_director ["app/utils/handleXhrErrorResponse.tsx"], ) } - auto_source_code_config(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 == "./" @@ -269,7 +272,7 @@ def test_auto_source_code_config_one_to_one_match(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["some/path/Test.tsx"]) } - auto_source_code_config(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 == "" @@ -285,7 +288,7 @@ def test_auto_source_code_config_same_trailing_substring(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/app.tsx"]) } - auto_source_code_config(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() @@ -319,7 +322,7 @@ def test_auto_source_code_config_rb(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["some/path/test.rb"]) } - auto_source_code_config(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 == "" @@ -334,7 +337,7 @@ def test_auto_source_code_config_rake(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["lib/tasks/crontask.rake"]) } - auto_source_code_config(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 == "" @@ -379,7 +382,7 @@ def test_auto_source_code_config_starts_with_app(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["utils/errors.js"]) } - auto_source_code_config(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 == "" @@ -393,7 +396,7 @@ def test_auto_source_code_config_starts_with_app_complex(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/utils/errors.js"]) } - auto_source_code_config(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/" @@ -408,7 +411,7 @@ def test_auto_source_code_config_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"]) } - auto_source_code_config(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 == "" @@ -425,7 +428,7 @@ def test_auto_source_code_config_starts_with_app_dot_dot_slash(self): Repo(repo_name, "master"), ["services/event/EventLifecycle/index.js"] ) } - auto_source_code_config(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 == "" @@ -464,7 +467,7 @@ def test_auto_source_code_config_go_abs_filename(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/capybara.go"]) } - auto_source_code_config(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 == "" @@ -479,7 +482,7 @@ def test_auto_source_code_config_go_long_abs_filename(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/kangaroo.go"]) } - auto_source_code_config(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 == "" @@ -494,7 +497,7 @@ def test_auto_source_code_config_similar_but_incorrect_file(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["notsentry/main.go"]) } - auto_source_code_config(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() @@ -523,7 +526,7 @@ def test_auto_source_code_config_basic_php(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/potato/kangaroo.php"]) } - auto_source_code_config(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 == "" @@ -538,7 +541,7 @@ def test_auto_source_code_config_different_roots_php(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["src/sentry/potato/kangaroo.php"]) } - auto_source_code_config(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/" @@ -578,7 +581,7 @@ def test_auto_source_code_config_csharp_trivial(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/potato/kangaroo.cs"]) } - auto_source_code_config(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 == "" @@ -593,7 +596,7 @@ def test_auto_source_code_config_different_roots_csharp(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["src/sentry/potato/kangaroo.cs"]) } - auto_source_code_config(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/" @@ -608,7 +611,7 @@ def test_auto_source_code_config_non_in_app_frame(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/src/functions.cs"]) } - auto_source_code_config(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() @@ -662,7 +665,7 @@ def test_auto_source_code_config_single_project( ) as mock_identify_stacktraces, self.tasks(), ): - auto_source_code_config(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_auto_source_code_config_single_project( def test_skips_not_supported_platforms(self): event = self.create_event([{}], platform="elixir") - assert auto_source_code_config(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") @@ -718,7 +721,7 @@ def test_auto_source_code_config_duplicates( ) as mock_identify_stacktraces, self.tasks(), ): - auto_source_code_config(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 @@ -736,7 +739,7 @@ def test_auto_source_code_config_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"]) } - auto_source_code_config(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/" @@ -751,7 +754,7 @@ def test_auto_source_code_config_no_normalization(self): mock_get_trees_for_org.return_value = { repo_name: RepoTree(Repo(repo_name, "master"), ["sentry/models/release.py"]) } - auto_source_code_config(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 == "" From d32f220120a2b4551a9b04be30b51d2aabbf279f Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Mon, 20 Jan 2025 11:53:12 -0500 Subject: [PATCH 14/74] fix(dashboards): Tooltip for all teams with access in access selector (#83681) There was a request to have a tooltip displaying all teams that have access to a dashboard in [this slack thread](https://sentry.slack.com/archives/C072KUA8R1U/p1736457766301069). Now hovering over the +(a number) bubble in the avatar list will give a tooltip of all the teams (some of them are scrollable due to the large amount of teams). Here's what it looks like: image --- static/app/components/avatar/avatarList.tsx | 37 ++++++--- .../views/dashboards/editAccessSelector.tsx | 76 ++++++++++++++++++- 2 files changed, 101 insertions(+), 12 deletions(-) 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/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; +`; From 095dc956dfad7fe73043a6372891e660caa8026a Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Mon, 20 Jan 2025 11:55:55 -0500 Subject: [PATCH 15/74] fix(charts): Multiline chart legend names (#83709) When rendering series names that contain newline characters, make sure we format them nicely by replacing them with a single white space. Also lifts the fix for escaped character names in the legend into the legend function directly instead of using an override in the insights chart component that loses the truncation formatter. --- .../components/charts/components/legend.tsx | 10 +++++- static/app/components/charts/utils.tsx | 34 ++++++++++++++----- static/app/views/explore/charts/index.tsx | 2 -- .../insights/common/components/chart.tsx | 14 ++++---- 4 files changed, 43 insertions(+), 17 deletions(-) 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/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/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 = { From 73735759cff841fbd59a04cf451eba0d2a9e017a Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Mon, 20 Jan 2025 13:21:37 -0500 Subject: [PATCH 16/74] chore(explore): Limit explore to 25 rows (#83674) Lower explore to 25 rows. --- static/app/views/explore/content.tsx | 4 ++++ static/app/views/explore/hooks/useExploreAggregatesTable.tsx | 3 +++ static/app/views/explore/hooks/useExploreSpansTable.tsx | 3 +++ 3 files changed, 10 insertions(+) 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/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, }); From 2b3bd345ba0fd3a13a9b50792403573b82edebcd Mon Sep 17 00:00:00 2001 From: Ash <0Calories@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:18:40 -0500 Subject: [PATCH 17/74] fix(trace-view): Fix header height in Issues view (#83712) Recently, I've changed the header height for the trace view to better match the designs, but forgot to update the height for the trace view preview on Issue Details. This will stop the bottom of the preview from getting cut off early --- .../views/performance/newTraceDetails/issuesTraceWaterfall.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From d05d90134babd29e371e74a3f0dc7859f6699cef Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Mon, 20 Jan 2025 15:11:08 -0500 Subject: [PATCH 18/74] fix(dashboards-eap): Remove explore from widget title (#83715) --- .../explore/hooks/useAddToDashboard.spec.tsx | 16 ++++++++-------- .../views/explore/hooks/useAddToDashboard.tsx | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) 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(), From d1a9c31ad2f4724d468a8f3f97f1feeba2906f20 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Mon, 20 Jan 2025 15:15:00 -0500 Subject: [PATCH 19/74] feat(dashboards-eap): Wrap menu item with dashboards upsell (#83713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the menu item for "Add to Dashboard" with a feature component to hook into for upselling. The implementation is a little wonky here because we don't typically flag menu items like this, but we need it to disable the Add to Dashboard button when a user clicks it. If the user doesn't have `dashboards-edit` access, we should disable the item. If they don't have `dashboards-eap` access, then we shouldn't show the menu item at all. Ideally, if I went to see this on a team plan I would see the popover telling me I need the business plan when I hover. ![Screenshot 2025-01-20 at 2 08 19 PM](https://github.com/user-attachments/assets/c91b9366-eb8c-479c-9593-7a8c089d8e33) --- .../explore/components/chartContextMenu.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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}; +`; From 7557b5005cffb907a82a9851b0a6457ce20a0e5d Mon Sep 17 00:00:00 2001 From: Abdullah Khan <60121741+Abdkhan14@users.noreply.github.com> Date: Mon, 20 Jan 2025 15:41:58 -0500 Subject: [PATCH 20/74] fix(new-trace): Adding back webvitals background colours (#83716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: Screenshot 2025-01-20 at 2 59 11 PM After: Screenshot 2025-01-20 at 2 58 50 PM Co-authored-by: Abdullah Khan --- .../performance/newTraceDetails/trace.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) 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); + } } } From 09c424002abaa237b3e5dcdbbe3cead7f91bb620 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Mon, 20 Jan 2025 16:22:00 -0500 Subject: [PATCH 21/74] fix(insights): Close span samples panel when navigating to a trace view (#83699) Right now, clicking on a span ID in the panel keeps the panel open, and navigates to the trace view. Closing the panel then calls the `onClose` callback, and sometimes clobbers the URL. Navigating to the sample view should close the panel, this PR adds that check. This is a hotfix, the proper solution is to make the navigation behaviour more robust, probably by rendering the panel view at a `/samples/` sub-route, which is easier to detect on navigation. --- .../views/insights/common/utils/useSamplesDrawer.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/static/app/views/insights/common/utils/useSamplesDrawer.tsx b/static/app/views/insights/common/utils/useSamplesDrawer.tsx index c75c65f61a26ac..ec29b5e8e16a13 100644 --- a/static/app/views/insights/common/utils/useSamplesDrawer.tsx +++ b/static/app/views/insights/common/utils/useSamplesDrawer.tsx @@ -54,7 +54,15 @@ export function useSamplesDrawer({ const shouldCloseOnLocationChange = useCallback( (newLocation: Location) => { - 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] ); From cad2e637a18498dce726598b1a2c9b0de181cef2 Mon Sep 17 00:00:00 2001 From: Ogi Date: Tue, 21 Jan 2025 11:40:10 +0100 Subject: [PATCH 22/74] feat(demo-mode): utility functions (#83722) --- src/sentry/utils/demo_mode.py | 49 ++++++++++++++++ tests/sentry/utils/test_demo_mode.py | 88 ++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/sentry/utils/demo_mode.py create mode 100644 tests/sentry/utils/test_demo_mode.py 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/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") From 2bbe4fd2787dd42a0bfa1772f23905fbe1f6c087 Mon Sep 17 00:00:00 2001 From: Joris Bayer Date: Tue, 21 Jan 2025 12:07:00 +0100 Subject: [PATCH 23/74] chore(relay): Bump relay to 0.9.5 (#83708) This PR updates the relay library and removes default cardinality limits for profiling metrics, which relay does not interpret anymore. --- requirements-base.txt | 2 +- requirements-dev-frozen.txt | 2 +- requirements-frozen.txt | 2 +- src/sentry/sentry_metrics/use_case_id_registry.py | 1 - .../test_config/test_get_project_config/REGION.pysnap | 9 +-------- .../False/REGION.pysnap | 9 +-------- .../True/REGION.pysnap | 10 +--------- 7 files changed, 6 insertions(+), 29 deletions(-) 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..853915e545d4b9 100644 --- a/requirements-dev-frozen.txt +++ b/requirements-dev-frozen.txt @@ -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 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/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/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 From 94a5865f0fac911d2bed5ab2ce950bb0e93fedfd Mon Sep 17 00:00:00 2001 From: Tobias Wilfert <36408720+tobias-wilfert@users.noreply.github.com> Date: Tue, 21 Jan 2025 12:35:11 +0100 Subject: [PATCH 24/74] fix(tempest): Change request logic (#83723) --- src/sentry/tempest/tasks.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) 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 From 1f49592704d3b22bc7e9f7d447b8b4a2ff7a4e03 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Tue, 21 Jan 2025 08:44:03 -0500 Subject: [PATCH 25/74] feat(widget-builder): Add validated widget response to filter (#83718) Uses the `validateWidgetQuery` hook to get widget validation issues/warnings for ondemand. This seems to apply to the group by field and filter fields. --- .../buildSteps/filterResultsStep/index.tsx | 2 +- .../components/groupBySelector.spec.tsx | 4 ++-- .../components/groupBySelector.tsx | 18 ++++++++------- .../components/queryFilterBuilder.spec.tsx | 20 ++++++++++++---- .../components/queryFilterBuilder.tsx | 23 +++++++++++++++++-- .../components/widgetBuilderSlideout.tsx | 11 ++++++++- 6 files changed, 60 insertions(+), 18 deletions(-) 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/queryFilterBuilder.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.spec.tsx index c9cb662d7a52aa..c8bbce54090c80 100644 --- a/static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.spec.tsx @@ -33,7 +33,10 @@ describe('QueryFilterBuilder', () => { 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/widgetBuilderSlideout.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx index d50f0c64d7620a..02379254ac26c3 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx @@ -13,6 +13,7 @@ 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, @@ -37,6 +38,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; @@ -74,6 +76,10 @@ function WidgetBuilderSlideout({ const {widgetIndex} = useParams(); const theme = useTheme(); + const validatedWidgetResponse = useValidateWidgetQuery( + convertBuilderStateToWidget(state) + ); + const isEditing = widgetIndex !== undefined; const title = openWidgetTemplates ? t('Add from Widget Library') @@ -176,6 +182,7 @@ function WidgetBuilderSlideout({
{state.displayType === DisplayType.BIG_NUMBER && ( @@ -188,7 +195,9 @@ function WidgetBuilderSlideout({ )} {isChartWidget && (
- +
)} {showSortByStep && ( From 2c03fbcfe2aa280a2875e66146e1fdd182ed590f Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 21 Jan 2025 14:52:37 +0100 Subject: [PATCH 26/74] Java SDK v8 onboarding (#82994) Use `sentry-opentelemetry-agent` for Java SDK v8 onboarding. ### Legal Boilerplate Look, I get it. The entity doing business as "Sentry" was incorporated in the State of Delaware in 2015 as Functional Software, Inc. and is gonna need some rights from me in order to utilize my contributions in this here PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Sentry can use, modify, copy, and redistribute my contributions, under Sentry's choice of terms. --- .../onboarding/productSelection.tsx | 3 + static/app/gettingStartedDocs/java/java.tsx | 88 ++++++++++++-- static/app/gettingStartedDocs/java/log4j2.tsx | 107 +++++++++++++++--- .../app/gettingStartedDocs/java/logback.tsx | 107 +++++++++++++++--- .../gettingStartedDocs/java/spring-boot.tsx | 42 +++++++ static/app/gettingStartedDocs/java/spring.tsx | 84 ++++++++++++++ 6 files changed, 394 insertions(+), 37 deletions(-) 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/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), + }, + ], + }, + ] + : []), ], }, ], From 1951e28d22e0dd48face4d24ce8b92b089df2af5 Mon Sep 17 00:00:00 2001 From: Ogi Date: Tue, 21 Jan 2025 15:06:55 +0100 Subject: [PATCH 27/74] fix(source-maps): release bundle details (#83730) --- static/app/utils/string/isUUID.spec.tsx | 25 +++++++++++ static/app/utils/string/isUUID.tsx | 4 ++ .../projectSourceMaps/sourceMapsDetails.tsx | 43 +++++++------------ 3 files changed, 44 insertions(+), 28 deletions(-) create mode 100644 static/app/utils/string/isUUID.spec.tsx create mode 100644 static/app/utils/string/isUUID.tsx 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/views/settings/projectSourceMaps/sourceMapsDetails.tsx b/static/app/views/settings/projectSourceMaps/sourceMapsDetails.tsx index 82ca6b645a1d44..c6fe8eb499eed9 100644 --- a/static/app/views/settings/projectSourceMaps/sourceMapsDetails.tsx +++ b/static/app/views/settings/projectSourceMaps/sourceMapsDetails.tsx @@ -21,7 +21,7 @@ import type {Artifact} from 'sentry/types/release'; import type {DebugIdBundleArtifact} from 'sentry/types/sourceMaps'; import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient'; import {decodeScalar} from 'sentry/utils/queryString'; -import normalizeUrl from 'sentry/utils/url/normalizeUrl'; +import {isUUID} from 'sentry/utils/string/isUUID'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; @@ -122,12 +122,7 @@ export function SourceMapsDetails({params, location, router, project}: Props) { project.slug }/artifact-bundles/${encodeURIComponent(params.bundleId)}/files/`; - // debug id bundles tab url - const debugIdsUrl = normalizeUrl( - `/settings/${organization.slug}/projects/${project.slug}/source-maps/${params.bundleId}/` - ); - - const tabDebugIdBundlesActive = location.pathname === debugIdsUrl; + const isDebugIdBundle = isUUID(params.bundleId); const { data: artifactsData, @@ -143,7 +138,7 @@ export function SourceMapsDetails({params, location, router, project}: Props) { { staleTime: 0, placeholderData: keepPreviousData, - enabled: !tabDebugIdBundlesActive, + enabled: !isDebugIdBundle, } ); @@ -161,7 +156,7 @@ export function SourceMapsDetails({params, location, router, project}: Props) { { staleTime: 0, placeholderData: keepPreviousData, - enabled: tabDebugIdBundlesActive, + enabled: isDebugIdBundle, } ); @@ -193,37 +188,31 @@ export function SourceMapsDetails({params, location, router, project}: Props) { return ( ) } 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) { Date: Tue, 21 Jan 2025 15:11:36 +0100 Subject: [PATCH 28/74] fix(insights): Stats for mobile insights are empty unless a release is selected a second time (#83726) Right now the top stats for mobile insights (e.g. Avg Cold Start R1) remain empty, unless a release is picked a second time: https://github.com/user-attachments/assets/088e2945-c3b3-4d7a-8ecf-36b48aa417cb --- .../appStarts/views/screenSummaryPage.tsx | 5 ++- .../screenload/views/screenLoadSpansPage.tsx | 39 ++++++++++--------- 2 files changed, 23 insertions(+), 21 deletions(-) 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 && ( + + )} Date: Tue, 21 Jan 2025 09:15:55 -0500 Subject: [PATCH 29/74] ref: fix typing for some slack helpers (#83659) --- pyproject.toml | 3 --- src/sentry/integrations/slack/unfurl/discover.py | 6 +++++- src/sentry/integrations/slack/utils/channel.py | 10 +++++----- src/sentry/integrations/slack/utils/users.py | 12 +++++------- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 146a99a4daeeeb..542139c1ba5f46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -208,9 +208,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", 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( From 75e2ec77cb2e9de88390d34e799b08cfa1eb87d7 Mon Sep 17 00:00:00 2001 From: anthony sottile <103459774+asottile-sentry@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:16:11 -0500 Subject: [PATCH 30/74] ref: fix types for group_stream serializer (#83649) --- pyproject.toml | 2 - src/sentry/api/serializers/models/group.py | 4 +- .../api/serializers/models/group_stream.py | 254 ++++++++++++------ src/sentry/issues/endpoints/group_details.py | 2 +- .../issues/endpoints/project_group_index.py | 17 +- src/sentry/models/groupowner.py | 3 +- .../api/serializers/test_group_stream.py | 6 +- 7 files changed, 178 insertions(+), 110 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 542139c1ba5f46..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", @@ -299,7 +298,6 @@ disable_error_code = [ "override", "return-value", "typeddict-item", - "typeddict-unknown-key", "union-attr", "unreachable", "var-annotated", 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/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/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/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( From 0509663224bcfc926bc186022f72911fa0ecc526 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 21 Jan 2025 15:18:29 +0100 Subject: [PATCH 31/74] fix(project-settings): Fix placeholder not being shown in 'Store Minidumps As Attachments' field (#83727) --- static/app/components/forms/formField/index.tsx | 4 ++++ .../data/forms/projectSecurityAndPrivacyGroups.tsx | 5 +++-- static/app/types/project.tsx | 1 + .../settings/projectSecurityAndPrivacy/index.spec.tsx | 11 ++++++++++- tests/js/fixtures/project.ts | 1 + 5 files changed, 19 insertions(+), 3 deletions(-) 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/data/forms/projectSecurityAndPrivacyGroups.tsx b/static/app/data/forms/projectSecurityAndPrivacyGroups.tsx index 087b84a70adcc6..64cad1dc0a7eb5 100644 --- a/static/app/data/forms/projectSecurityAndPrivacyGroups.tsx +++ b/static/app/data/forms/projectSecurityAndPrivacyGroups.tsx @@ -82,9 +82,10 @@ const formGroups: JsonFormObject[] = [ } ), visible: ({features}) => 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/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/views/settings/projectSecurityAndPrivacy/index.spec.tsx b/static/app/views/settings/projectSecurityAndPrivacy/index.spec.tsx index b8adf72054965a..f89d630d6f1d5c 100644 --- a/static/app/views/settings/projectSecurityAndPrivacy/index.spec.tsx +++ b/static/app/views/settings/projectSecurityAndPrivacy/index.spec.tsx @@ -1,3 +1,4 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; import {ProjectFixture} from 'sentry-fixture/project'; import {initializeOrg} from 'sentry-test/initializeOrg'; @@ -7,7 +8,7 @@ import ProjectSecurityAndPrivacy from 'sentry/views/settings/projectSecurityAndP describe('projectSecurityAndPrivacy', function () { it('renders form fields', function () { - const {organization} = initializeOrg(); + const organization = OrganizationFixture({features: ['event-attachments']}); const project = ProjectFixture({ sensitiveFields: ['creditcard', 'ssn'], safeFields: ['business-email', 'company'], @@ -15,6 +16,14 @@ describe('projectSecurityAndPrivacy', function () { render(); + // 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/tests/js/fixtures/project.ts b/tests/js/fixtures/project.ts index e2d9672e44a29f..90550b4605e7ce 100644 --- a/tests/js/fixtures/project.ts +++ b/tests/js/fixtures/project.ts @@ -59,6 +59,7 @@ export function ProjectFixture(params: Partial = {}): Project { sensitiveFields: [], subjectTemplate: '', verifySSL: false, + storeCrashReports: null, ...params, }; } From 0d3cc733a5c3ca5f9af1b139602e5759af2229cb Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 21 Jan 2025 15:41:05 +0100 Subject: [PATCH 32/74] ref(onboarding): Remove 'Set up My Team' option (#83731) --- .../app/views/onboarding/onboarding.spec.tsx | 1 - static/app/views/onboarding/welcome.tsx | 28 +------------------ 2 files changed, 1 insertion(+), 28 deletions(-) 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)}; From 02f3935cd0ad104429f6182ba5119111458ab050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vjeran=20Grozdani=C4=87?= Date: Tue, 21 Jan 2025 15:44:45 +0100 Subject: [PATCH 33/74] feat(tempest): error messages for tempest (#82856) Display error messages for tempest credentials. This is the only way to communicate to user that there is some problem with following credentials. Similar to how it is done with [Crons in Sentry,](https://sentry.sentry.io/crons/?project=-1&statsPeriod=90d) we will show those errors on top of the page as they are the most important information and need to be addressed ASAP. ![image](https://github.com/user-attachments/assets/3b85c4d1-5771-4fde-b693-cb5ff33e300c) --------- Signed-off-by: Vjeran Grozdanic --- .../views/settings/project/tempest/index.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/static/app/views/settings/project/tempest/index.tsx b/static/app/views/settings/project/tempest/index.tsx index fd3ec754f57a90..ee0be7fe5dea19 100644 --- a/static/app/views/settings/project/tempest/index.tsx +++ b/static/app/views/settings/project/tempest/index.tsx @@ -1,4 +1,4 @@ -import {Fragment} from 'react'; +import {Fragment, useMemo} from 'react'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openAddTempestCredentialsModal} from 'sentry/actionCreators/modal'; @@ -6,6 +6,8 @@ import Alert from 'sentry/components/alert'; import {Button} from 'sentry/components/button'; import Form from 'sentry/components/forms/form'; import JsonForm from 'sentry/components/forms/jsonForm'; +import List from 'sentry/components/list'; +import ListItem from 'sentry/components/list/listItem'; import {PanelTable} from 'sentry/components/panels/panelTable'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {Tooltip} from 'sentry/components/tooltip'; @@ -19,6 +21,7 @@ import {hasTempestAccess} from 'sentry/utils/tempest/features'; import useApi from 'sentry/utils/useApi'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import {useFetchTempestCredentials} from 'sentry/views/settings/project/tempest/hooks/useFetchTempestCredentials'; +import {MessageType} from 'sentry/views/settings/project/tempest/types'; import {useHasTempestWriteAccess} from 'sentry/views/settings/project/tempest/utils/access'; import {CredentialRow} from './CredentialRow'; @@ -60,6 +63,12 @@ export default function TempestSettings({organization, project}: Props) { }, }); + const credentialErrors = useMemo(() => { + 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} + + ))} + + + )} +

Date: Tue, 21 Jan 2025 15:46:39 +0100 Subject: [PATCH 34/74] fix(project-settings): If storeCrashReports is null remove '"sentry:store_crash_reports"' value from project options (#83728) --- src/sentry/api/endpoints/project_details.py | 2 +- tests/sentry/api/endpoints/test_project_details.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) 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/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) From 593326adaf4dd2e422204b9f25c86f9c7a44b48d Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:53:23 -0500 Subject: [PATCH 35/74] fix(widget-builder): Display visual confirmation when saving widget (#83711) Since widgets take some time to save and add to the dashboard, I've added some visual confirmation that a widget is being saved. When a widget has passed validation and is going through the saving process the button is busy/unclickable and a loading message will pop up to say that the widget is saving. Here it is in action: https://github.com/user-attachments/assets/710418c4-7ec3-42f8-958c-36f7a8e4f3aa --- .../widgetBuilder/components/saveButton.tsx | 15 ++++-- .../components/widgetBuilderSlideout.spec.tsx | 48 ++++++++++++++++++- 2 files changed, 59 insertions(+), 4 deletions(-) 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/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( From 0e89a6de63f6258a44c8437d7a2608b6f6ececea Mon Sep 17 00:00:00 2001 From: William Mak Date: Tue, 21 Jan 2025 10:11:20 -0500 Subject: [PATCH 36/74] feat(rpc): Remove spans specific code (#83714) - Removes spans specific code from the SearchResolver so that it can be reused by logs --- .../endpoints/organization_spans_fields.py | 12 +- src/sentry/search/eap/columns.py | 525 +-------------- src/sentry/search/eap/constants.py | 6 +- .../search/eap/{spans.py => resolver.py} | 25 +- src/sentry/search/eap/span_columns.py | 607 ++++++++++++++++++ src/sentry/snuba/spans_rpc.py | 17 +- tests/sentry/search/eap/test_spans.py | 11 +- 7 files changed, 659 insertions(+), 544 deletions(-) rename src/sentry/search/eap/{spans.py => resolver.py} (97%) create mode 100644 src/sentry/search/eap/span_columns.py 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/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/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/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): From 8ebe859f3b9db65902ec31abb399f2e2607ebfbc Mon Sep 17 00:00:00 2001 From: Jenn Mueng <30991498+jennmueng@users.noreply.github.com> Date: Tue, 21 Jan 2025 07:38:26 -0800 Subject: [PATCH 37/74] fix(seer-api): Remove request url from signature (#83593) _Previously request signing was disabled due to a quirk in our networking setup that made the request url passed to the client signer not the same as the request seen on the server side._ This changes the request signing to match the seer side that no longer checks the request url but has an optional nonce. --- src/sentry/api/endpoints/group_ai_autofix.py | 10 ++---- src/sentry/api/endpoints/group_ai_summary.py | 10 ++---- .../endpoints/group_autofix_setup_check.py | 10 ++---- .../api/endpoints/group_autofix_update.py | 10 ++---- src/sentry/autofix/utils.py | 18 +++------- src/sentry/options/defaults.py | 6 ---- src/sentry/seer/signed_seer_api.py | 31 +++++++--------- .../endpoints/test_group_autofix_update.py | 5 ++- tests/sentry/event_manager/test_severity.py | 8 ++--- .../test_organization_alert_rule_anomalies.py | 2 +- .../incidents/test_subscription_processor.py | 10 +++--- .../test_group_similar_issues_embeddings.py | 8 ++--- tests/sentry/seer/test_signed_seer_api.py | 36 ++++++------------- 13 files changed, 55 insertions(+), 109 deletions(-) diff --git a/src/sentry/api/endpoints/group_ai_autofix.py b/src/sentry/api/endpoints/group_ai_autofix.py index 50a2ea2c0e057d..fc8e6b6fe96f8b 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 @@ -289,16 +289,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/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/options/defaults.py b/src/sentry/options/defaults.py index ca1d4f4cebf525..efe39d34ffcae8 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -2823,12 +2823,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/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/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/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/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/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/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/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"}, ) From cac136e7ada8ca56be15a0e8a4f505757d289eef Mon Sep 17 00:00:00 2001 From: William Mak Date: Tue, 21 Jan 2025 10:54:31 -0500 Subject: [PATCH 38/74] chore(rpc): add instrumentation of rpc usage to stats (#83733) - had this in events but not events-stats --- src/sentry/api/endpoints/organization_events_stats.py | 1 + 1 file changed, 1 insertion(+) 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, From 6698fe34595f290577a93fa4dc72ecc97e7f60be Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 21 Jan 2025 10:57:47 -0500 Subject: [PATCH 39/74] feat(profiling): Support most common stacks for continuous profiles (#83736) This adds support for the most common stacks feature for continuous profiles. --- .../interfaces/spans/spanProfileDetails.tsx | 76 +++++++++++++------ .../traceDrawer/details/span/index.tsx | 37 ++++++++- 2 files changed, 84 insertions(+), 29 deletions(-) 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/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; +} From 915e7d29a6bb6162da683ea929f18d167f6a1541 Mon Sep 17 00:00:00 2001 From: anthony sottile <103459774+asottile-sentry@users.noreply.github.com> Date: Tue, 21 Jan 2025 11:00:47 -0500 Subject: [PATCH 40/74] ref: upgrade types-requests-oauthlib (#83735) fixes a handful of errors in ignored files such as: ``` src/sentry_plugins/bitbucket/client.py:26: error: Argument "decoding" to "OAuth1" has incompatible type "None"; expected "str" [arg-type] ``` --- requirements-dev-frozen.txt | 2 +- requirements-dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev-frozen.txt b/requirements-dev-frozen.txt index 853915e545d4b9..8625e98f40b8fc 100644 --- a/requirements-dev-frozen.txt +++ b/requirements-dev-frozen.txt @@ -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..b80775f9254a36 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 From 4170cbdc3d76638386d86a01e86985ce1333f984 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 21 Jan 2025 11:06:14 -0500 Subject: [PATCH 41/74] ref(dashboards): Push `SeriesConstructor` prop lower down (#83720) Right now, if you need to use `TimeseriesWidget`, it's a bit awkward. You need to import `TimeseriesWidget` _and_ a series constructor to do this: ```jsx {} + 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/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..d9e5404c43a400 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx @@ -18,7 +18,9 @@ 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) && ( 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/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 ( - Date: Tue, 21 Jan 2025 12:10:15 -0500 Subject: [PATCH 42/74] =?UTF-8?q?=F0=9F=94=A7=20=20chore(pagerduty):=20Sup?= =?UTF-8?q?port=20"default"=20for=20custom=20severity=20config=20(#83564)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sentry/integrations/pagerduty/utils.py | 3 ++- .../notifications/notifications/registries/__init__.py | 0 .../notifications/registries/thread_lookup_registry | 0 tests/sentry/incidents/action_handlers/__init__.py | 6 ++---- tests/sentry/incidents/action_handlers/test_pagerduty.py | 6 ++++++ 5 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 src/sentry/notifications/notifications/registries/__init__.py create mode 100644 src/sentry/notifications/notifications/registries/thread_lookup_registry 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/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/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) From 49ee2771de807c9ff1009c4a522d87ce0ca54791 Mon Sep 17 00:00:00 2001 From: Colleen O'Rourke Date: Tue, 21 Jan 2025 09:21:12 -0800 Subject: [PATCH 43/74] feat(ACI): Call alert rule trigger migration helpers (#83623) Follow up to https://github.com/getsentry/sentry/pull/83562 to call the migration helpers for alert rule triggers. Addresses https://getsentry.atlassian.net/browse/ACI-76 --- .../incidents/serializers/alert_rule.py | 9 +- .../serializers/alert_rule_trigger.py | 10 +- .../migration_helpers/alert_rule.py | 17 +-- .../test_organization_alert_rule_index.py | 7 + .../test_migrate_alert_rule.py | 130 ++++++++---------- 5 files changed, 89 insertions(+), 84 deletions(-) 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/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/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/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) From 3a5a82b4d353884c834f5a222011e1de6ff8fde9 Mon Sep 17 00:00:00 2001 From: "sentry-autofix[bot]" <157164994+sentry-autofix[bot]@users.noreply.github.com> Date: Tue, 21 Jan 2025 09:22:34 -0800 Subject: [PATCH 44/74] =?UTF-8?q?=F0=9F=A4=96=20Refactor=20AI=20Autofix=20?= =?UTF-8?q?Profile=20Processing=20Logic=20(#83738)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 👋 Hi there! This PR was automatically generated by Autofix 🤖 This fix was triggered by Rohan Agarwal Fixes [SENTRY-3MMH](https://sentry.io/organizations/sentry/issues/6234451280/) This commit refactors the logic within the group AI autofix endpoint to improve robustness when handling profile data. It adds checks for the existence of 'profile', 'frames', 'stacks', and 'samples' to ensure that the code gracefully handles scenarios where these elements may be missing. Additionally, it modifies the output structure to only generate a profile object if an execution tree is present, consolidating the results better. Overall, these changes aim to enhance the error handling and prevent potential issues when consuming profile data. If you have any questions or feedback for the Sentry team about this fix, please email [autofix@sentry.io](mailto:autofix@sentry.io) with the Run ID: 3414. --------- Co-authored-by: sentry-autofix[bot] <157164994+sentry-autofix[bot]@users.noreply.github.com> Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- src/sentry/api/endpoints/group_ai_autofix.py | 32 +++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/sentry/api/endpoints/group_ai_autofix.py b/src/sentry/api/endpoints/group_ai_autofix.py index fc8e6b6fe96f8b..577153acf0da4a 100644 --- a/src/sentry/api/endpoints/group_ai_autofix.py +++ b/src/sentry/api/endpoints/group_ai_autofix.py @@ -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") + + if not all([frames, stacks, samples]): + return [] - thread_metadata = profile.get("thread_metadata", {}) + 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) From 37e937cad90a35009381825da047286eced124ba Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Tue, 21 Jan 2025 12:29:59 -0500 Subject: [PATCH 45/74] :sparkles: feat(notification): add support for `open_period_start` for issue alert thread repo (#83601) --- .../integrations/repository/issue_alert.py | 26 ++++- ...e_alert_notification_message_repository.py | 96 +++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) 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/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 From b286ba003f80781f54a340b1fdf751959dd7563a Mon Sep 17 00:00:00 2001 From: Josh Ferge Date: Tue, 21 Jan 2025 12:41:48 -0500 Subject: [PATCH 46/74] ref(typing): type bitbucket issues file (#83644) --- pyproject.toml | 1 - src/sentry/integrations/bitbucket/issues.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 95e441951d10ed..00735ffb911321 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,7 +175,6 @@ module = [ "sentry.integrations.aws_lambda.integration", "sentry.integrations.bitbucket.client", "sentry.integrations.bitbucket.integration", - "sentry.integrations.bitbucket.issues", "sentry.integrations.bitbucket_server.client", "sentry.integrations.bitbucket_server.integration", "sentry.integrations.example.integration", diff --git a/src/sentry/integrations/bitbucket/issues.py b/src/sentry/integrations/bitbucket/issues.py index 50880706e9e6c3..babf460d75990a 100644 --- a/src/sentry/integrations/bitbucket/issues.py +++ b/src/sentry/integrations/bitbucket/issues.py @@ -45,6 +45,7 @@ def get_create_issue_config( params = kwargs.pop("params", {}) default_repo, repo_choices = self.get_repository_choices(group, params, **kwargs) + assert group is not None org = group.organization autocomplete_url = reverse( "sentry-extensions-bitbucket-search", args=[org.slug, self.model.id] From 6570a65c409026c99d19ffbba5e35a4808937e93 Mon Sep 17 00:00:00 2001 From: Cathy Teng <70817427+cathteng@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:01:45 -0800 Subject: [PATCH 47/74] chore(gitlab): emit halt for 400 and 404s when checking a file (#83684) --- .../integrations/source_code_management/repository.py | 9 +++++---- tests/sentry/integrations/gitlab/test_integration.py | 5 ++++- 2 files changed, 9 insertions(+), 5 deletions(-) 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/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() From 3caa22e8706a440e9d294620dab806d481dc66fd Mon Sep 17 00:00:00 2001 From: Cathy Teng <70817427+cathteng@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:02:34 -0800 Subject: [PATCH 48/74] feat(aci): enqueue workflows for delayed processing (#83548) --- .../handlers/condition/__init__.py | 3 + .../condition/event_frequency_handlers.py | 23 ++-- .../workflow_engine/models/data_condition.py | 24 ++-- src/sentry/workflow_engine/models/workflow.py | 13 ++ .../processors/data_condition_group.py | 7 +- .../workflow_engine/processors/workflow.py | 65 +++++++++- src/sentry/workflow_engine/types.py | 1 + .../handlers/condition/test_base.py | 7 -- .../test_event_frequency_handlers.py | 23 +++- .../processors/test_workflow.py | 116 +++++++++++++++++- 10 files changed, 238 insertions(+), 44 deletions(-) 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/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/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/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 From d468f98b442d9102c6b5f9a6108aa7c26b9aeaa6 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 21 Jan 2025 10:08:34 -0800 Subject: [PATCH 49/74] fix(feedback): Remove duplicate assignment request (#83669) --- static/app/actionCreators/group.tsx | 13 ++- .../feedbackItem/feedbackAssignedTo.spec.tsx | 93 +++++++++++++++++++ .../feedbackItem/feedbackAssignedTo.tsx | 14 +-- .../components/feedback/useMutateFeedback.tsx | 11 --- .../app/components/group/assigneeSelector.tsx | 22 ++--- 5 files changed, 117 insertions(+), 36 deletions(-) create mode 100644 static/app/components/feedback/feedbackItem/feedbackAssignedTo.spec.tsx diff --git a/static/app/actionCreators/group.tsx b/static/app/actionCreators/group.tsx index 00f465f02ad8ff..ccf89257077342 100644 --- a/static/app/actionCreators/group.tsx +++ b/static/app/actionCreators/group.tsx @@ -61,7 +61,7 @@ export function clearAssignment( groupId: string, orgSlug: string, assignedBy: AssignedBy -) { +): Promise { const api = new Client(); const endpoint = `/organizations/${orgSlug}/issues/${groupId}/`; @@ -84,9 +84,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 +104,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,9 +142,11 @@ 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; }); } 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/group/assigneeSelector.tsx b/static/app/components/group/assigneeSelector.tsx index 90b6f6a65da19c..27147e2a46ee27 100644 --- a/static/app/components/group/assigneeSelector.tsx +++ b/static/app/components/group/assigneeSelector.tsx @@ -14,7 +14,6 @@ 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; @@ -29,36 +28,31 @@ 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'); From 614cddcc2f57a9a9fa6ab396141cccbbff2e145a Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 21 Jan 2025 18:17:21 +0000 Subject: [PATCH 50/74] Revert "ref(typing): type bitbucket issues file (#83644)" This reverts commit b286ba003f80781f54a340b1fdf751959dd7563a. Co-authored-by: JoshFerge <1976777+JoshFerge@users.noreply.github.com> --- pyproject.toml | 1 + src/sentry/integrations/bitbucket/issues.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 00735ffb911321..95e441951d10ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,6 +175,7 @@ module = [ "sentry.integrations.aws_lambda.integration", "sentry.integrations.bitbucket.client", "sentry.integrations.bitbucket.integration", + "sentry.integrations.bitbucket.issues", "sentry.integrations.bitbucket_server.client", "sentry.integrations.bitbucket_server.integration", "sentry.integrations.example.integration", diff --git a/src/sentry/integrations/bitbucket/issues.py b/src/sentry/integrations/bitbucket/issues.py index babf460d75990a..50880706e9e6c3 100644 --- a/src/sentry/integrations/bitbucket/issues.py +++ b/src/sentry/integrations/bitbucket/issues.py @@ -45,7 +45,6 @@ def get_create_issue_config( params = kwargs.pop("params", {}) default_repo, repo_choices = self.get_repository_choices(group, params, **kwargs) - assert group is not None org = group.organization autocomplete_url = reverse( "sentry-extensions-bitbucket-search", args=[org.slug, self.model.id] From 23f21a08e218a57717cbe969e215de610979ccb2 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 21 Jan 2025 10:21:02 -0800 Subject: [PATCH 51/74] feat(issues): Add release package (#83692) --- .../app/components/versionHoverCard.spec.tsx | 2 + static/app/components/versionHoverCard.tsx | 46 +++++++++++++------ static/app/utils/versions/formatVersion.tsx | 20 ++++---- static/app/utils/versions/isSemverRelease.tsx | 10 ++-- static/app/utils/versions/parseVersion.tsx | 10 ++++ 5 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 static/app/utils/versions/parseVersion.tsx 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 ( { - 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; + } +} From c4721ebdccf9e9dadc89f80a41b35f1e8d400028 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 21 Jan 2025 13:29:24 -0500 Subject: [PATCH 52/74] ref(dashboards): Enforce widget padding inside `WidgetFrame` (#83744) Instead of having all the visualization widgets specify the correct padding, set the padding inside `WidgetFrame` itself. This makes it much easier to use `WidgetFrame` in other contexts, since the padding will be correct. No visual changes! --- .../dashboards/widgets/bigNumberWidget/bigNumberWidget.tsx | 3 --- .../widgets/bigNumberWidget/bigNumberWidgetVisualization.tsx | 4 +--- static/app/views/dashboards/widgets/common/errorPanel.tsx | 2 +- static/app/views/dashboards/widgets/common/widgetFrame.tsx | 1 + .../dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx | 3 +-- .../views/projectDetail/projectScoreCards/actionWrapper.tsx | 2 +- 6 files changed, 5 insertions(+), 10 deletions(-) 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/timeSeriesWidget/timeSeriesWidget.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx index d9e5404c43a400..2e9b486ad29bdd 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidget.tsx @@ -12,7 +12,7 @@ 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 @@ -76,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/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; `; From 7f04b93de51c913e51425c350cc63c94fbcd3746 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 21 Jan 2025 10:34:05 -0800 Subject: [PATCH 53/74] chore: Tidy up node & config files including excepting them from import/no-nodejs-modules (#83691) --- .github/workflows/scripts/deploy.js | 2 -- .github/workflows/scripts/getsentry-dispatch.js | 2 -- api-docs/index.ts | 7 +++---- api-docs/openapi-diff.ts | 4 +--- api-docs/watch.ts | 2 -- babel.config.ts | 2 -- build-utils/last-built-plugin.ts | 2 -- build-utils/sentry-instrumentation.ts | 1 - config/webpack.chartcuterie.config.ts | 2 -- eslint.config.mjs | 15 +++++++++++++-- jest.config.ts | 1 - stylelint.config.js | 1 - tests/js/sentry-test/loadFixtures.ts | 3 ++- tests/js/setup.ts | 2 +- webpack.config.ts | 2 -- 15 files changed, 20 insertions(+), 28 deletions(-) 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/eslint.config.mjs b/eslint.config.mjs index 1b341026c09c80..f491e75cb57ec1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -307,6 +307,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 @@ -597,13 +598,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', @@ -617,6 +622,8 @@ export default typescript.config([ }, rules: { 'no-console': 'off', + + 'import/no-nodejs-modules': 'off', }, }, { @@ -625,7 +632,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', @@ -633,7 +642,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/stylelint.config.js b/stylelint.config.js index 9ae5efae8112e2..e04dc29d49d2a6 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -1,4 +1,3 @@ -/* eslint-env node */ module.exports = { customSyntax: 'postcss-styled-syntax', extends: ['stylelint-config-recommended'], 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 737529f4233d36..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'; 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'; From e9f43d865f249611af8a75e7a8bf732286a3a1c0 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 21 Jan 2025 10:50:24 -0800 Subject: [PATCH 54/74] deps(ui): Upgrade react-refresh (#83750) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 8d650e48337788..3e40346520ac79 100644 --- a/package.json +++ b/package.json @@ -213,7 +213,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/yarn.lock b/yarn.lock index dade0f4618f4c7..0b32844d25d693 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10511,10 +10511,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" From 1c1c7c911a15dd70565cddb9d84a1c54c2713a35 Mon Sep 17 00:00:00 2001 From: Athena Moghaddam <132939361+sentaur-athena@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:54:19 -0800 Subject: [PATCH 55/74] chore(apis) Delete ProjectPlatformsEndpoint (#83210) This endpoint doesn't seem to be called from frontend and had no usage in the past 6 month. We probably replaced it by the project endpoint. --- src/sentry/api/endpoints/project_platforms.py | 21 ------------------- src/sentry/api/urls.py | 6 ------ .../api_pagination_allowlist_do_not_modify.py | 1 - .../api/endpoints/test_project_platforms.py | 13 ------------ 4 files changed, 41 deletions(-) delete mode 100644 src/sentry/api/endpoints/project_platforms.py delete mode 100644 tests/sentry/api/endpoints/test_project_platforms.py 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/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/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/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 From 0b216d7daf8502d7527aab66c1bfeb7315bf1a2a Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 21 Jan 2025 10:58:37 -0800 Subject: [PATCH 56/74] chore(eslint): Enable eslint rules that overlap with biome (#83694) A little followup to https://github.com/getsentry/sentry/pull/83693 This enables 3 rules from biome that are implemented in eslint as part of this plugin. This plugin has a lot of rules, the recommended set is too opinionated, but a handful would be nice to enable inside sentry. I'll make a list with TODO comments as a followup, and then work against that list rule by rule in followup PRs. For now we just need to pluck these three rules to match what's already happening. --- eslint.config.mjs | 22 ++++-- package.json | 1 + yarn.lock | 180 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 181 insertions(+), 22 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f491e75cb57ec1..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! @@ -497,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)(/.*|$)'], @@ -519,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`^\./?$`], ], }, ], @@ -554,6 +555,17 @@ 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}'], diff --git a/package.json b/package.json index 3e40346520ac79..94b59396b0d726 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 0b32844d25d693..bb56afe06d531e 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== @@ -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" @@ -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" @@ -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" From 94b0d73c682e9c422d1a6b07b54d50b7791e977d Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 21 Jan 2025 11:07:09 -0800 Subject: [PATCH 57/74] feat(dashboards): Replace assignee selector with only avatar (#83686) --- static/app/actionCreators/group.tsx | 111 +-- .../deprecatedAssigneeSelector.spec.tsx | 409 ----------- .../components/deprecatedAssigneeSelector.tsx | 210 ------ .../deprecatedAssigneeSelectorDropdown.tsx | 675 ------------------ static/app/components/group/assignedTo.tsx | 26 +- .../app/components/group/assigneeSelector.tsx | 8 +- static/app/utils/dashboards/issueAssignee.tsx | 58 ++ .../dashboards/issueFieldRenderers.spec.tsx | 26 +- .../utils/dashboards/issueFieldRenderers.tsx | 23 +- .../app/views/dashboards/dashboard.spec.tsx | 30 +- .../app/views/issueDetails/groupSidebar.tsx | 2 +- 11 files changed, 117 insertions(+), 1461 deletions(-) delete mode 100644 static/app/components/deprecatedAssigneeSelector.spec.tsx delete mode 100644 static/app/components/deprecatedAssigneeSelector.tsx delete mode 100644 static/app/components/deprecatedAssigneeSelectorDropdown.tsx create mode 100644 static/app/utils/dashboards/issueAssignee.tsx diff --git a/static/app/actionCreators/group.tsx b/static/app/actionCreators/group.tsx index ccf89257077342..41f67468250a17 100644 --- a/static/app/actionCreators/group.tsx +++ b/static/app/actionCreators/group.tsx @@ -4,58 +4,13 @@ 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, @@ -150,70 +105,6 @@ export function assignToActor({ }); } -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/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/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 27147e2a46ee27..f03a6700142d42 100644 --- a/static/app/components/group/assigneeSelector.tsx +++ b/static/app/components/group/assigneeSelector.tsx @@ -8,8 +8,8 @@ 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'; @@ -24,6 +24,12 @@ interface AssigneeSelectorProps { owners?: Omit[]; } +export type OnAssignCallback = ( + type: Actor['type'], + assignee: User | Actor, + suggestedAssignee?: SuggestedAssignee +) => void; + export function useHandleAssigneeChange({ organization, group, 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/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/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, { From 27f3baad2aebc806da9e5275ec2ae4612594de94 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 21 Jan 2025 14:21:20 -0500 Subject: [PATCH 58/74] feat(deps): Upgrade `@sentry/status-page-list` to `0.6.0` (#83761) https://github.com/getsentry/status-page-list/blob/main/CHANGELOG.md Adds entries for Jotform, Paubox, SendGrid, Autodesk, Etsy, InfluxData, Loom, Miro, Monday.com, PlanetScale, Pleo, Ravelin, Render, Rippling, Squarespace, Twilio, and Vanta. Improves entries for Atlassian and Vercel. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 94b59396b0d726..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", diff --git a/yarn.lock b/yarn.lock index bb56afe06d531e..0b8a1666059afa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From a2c423132f2680cc891f66fbadbd09ebf1b7099e Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 21 Jan 2025 14:36:13 -0500 Subject: [PATCH 59/74] fix(dx): Have devenv sync respect SENTRY_CONF env var (#83745) --- devenv/sync.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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")) From 0c3018ea63272f90a1b82f86cbf784662159c6af Mon Sep 17 00:00:00 2001 From: Nikki Kapadia <72356613+nikkikapadia@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:40:47 -0500 Subject: [PATCH 60/74] fix(widget-builder): Change default widget display type to line (#83740) There were suggestions from the bug bash to change the default display type for widgets in the widget builder to be line charts. Now all datasets except for Issues will default to line charts. --- .../utils/convertWidgetToBuilderStateParams.spec.tsx | 7 ++++++- .../widgetBuilder/utils/getDefaultWidget.spec.tsx | 6 +++--- .../dashboards/widgetBuilder/utils/getDefaultWidget.tsx | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) 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, From 5dd6273064a6c343547eb4564a2dde1383ed9de5 Mon Sep 17 00:00:00 2001 From: Nar Saynorath Date: Tue, 21 Jan 2025 14:42:54 -0500 Subject: [PATCH 61/74] feat(widget-builder): Aggregates without args take up the entire width (#83763) Aggregates that don't have an argument (e.g. `count()`) don't require you to select a column so we're going to not display the column selector. This enables the user so they can start selecting different values instead of seeing a disabled state. --- .../components/visualize.spec.tsx | 22 ++-- .../widgetBuilder/components/visualize.tsx | 109 +++++++++++------- 2 files changed, 75 insertions(+), 56 deletions(-) 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; + `} } `; From 4efbc6dd8db280540766e2d6f02b937510cac1f3 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 21 Jan 2025 14:53:38 -0500 Subject: [PATCH 62/74] feat(crons): Re-add constraint for trace_sampling (#83758) We attempted this in https://github.com/getsentry/sentry/pull/80749 However due to some overly agressive timeout restrictions this failed in US but applied in DE, leaving us in a weird un-deployable state. We removed the constraint here https://github.com/getsentry/sentry/pull/80876 But we do need this constraint, otherwise we're not able to toggle 'trace sampling' --- migrations_lockfile.txt | 2 +- ...2_add_trace_sampling_to_uptime_monitors.py | 57 +++++++++++++++++++ src/sentry/uptime/models.py | 3 +- 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/sentry/uptime/migrations/0022_add_trace_sampling_to_uptime_monitors.py 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/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", ), ] From 812889825e7c357cc261cdf30c4c821d299a8070 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 21 Jan 2025 11:57:25 -0800 Subject: [PATCH 63/74] ref(feedback): add info logs to debug missing_context filter (#83762) In the last month we saw 85% of new feedback envelopes filtered because they were missing a feedback context or message. 2% filtered because message was present, but empty. These logs will help us get some info on the specific orgs/projects this is happening for. --- .../feedback/usecases/create_feedback.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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 From 32574a33ce7b582c56acf5cb5aac403aa1aa6d14 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 21 Jan 2025 14:59:36 -0500 Subject: [PATCH 64/74] feat(alerts): Support crons on the frontend (#83747) Supports cron monitors in the alerts list --- static/app/components/badge/alertBadge.tsx | 20 ++++++-- .../rules/alertLastIncidentActivationInfo.tsx | 15 +++++- .../alerts/list/rules/alertRulesList.spec.tsx | 1 - .../alerts/list/rules/alertRulesList.tsx | 9 +++- .../alerts/list/rules/combinedAlertBadge.tsx | 31 ++++++++++++ static/app/views/alerts/list/rules/row.tsx | 50 ++++++++++--------- static/app/views/alerts/list/rules/utils.tsx | 3 ++ static/app/views/alerts/rules/crons/utils.tsx | 19 +++++++ static/app/views/alerts/types.tsx | 9 +++- 9 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 static/app/views/alerts/rules/crons/utils.tsx 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/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx index 906ebabfc6b7fb..19b4107882f18c 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 every [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/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}; From e9c450a76a23f97965e25a9a3f866792f540952f Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Tue, 21 Jan 2025 12:21:20 -0800 Subject: [PATCH 65/74] ref(flags): update unleash onboarding snippet js (#83768) based on https://github.com/getsentry/sentry-docs/pull/12338/commits/27ca5aa55778e6e16b361037d41f241463411c96 --- static/app/gettingStartedDocs/javascript/javascript.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: "", From 845d4ae2c370426e8fd7bbec6196c2af3142512e Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 21 Jan 2025 12:29:18 -0800 Subject: [PATCH 66/74] feat(ui): Switch bootstrap requests to fetch (#83561) --- .../sentry/partial/preload-data.html | 53 +++++++++++-------- static/app/actionCreators/organization.tsx | 9 +--- static/app/bootstrap/index.tsx | 53 +++++++++++-------- static/app/types/system.tsx | 11 +++- static/app/utils/getPreloadedData.spec.tsx | 10 ++-- static/app/utils/getPreloadedData.ts | 12 +++-- 6 files changed, 87 insertions(+), 61 deletions(-) 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/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/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/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; From 516b81ff6c6bb4905f002311576be7277d2ec70c Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Tue, 21 Jan 2025 16:03:18 -0500 Subject: [PATCH 67/74] fix(crons): Correct wording on cron alert row subtitle (#83770) The "Every" was already part of the interval text --- .../views/alerts/list/rules/alertLastIncidentActivationInfo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx index 19b4107882f18c..436ab8bc0f2ab8 100644 --- a/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx +++ b/static/app/views/alerts/list/rules/alertLastIncidentActivationInfo.tsx @@ -30,7 +30,7 @@ function LastUptimeIncident({rule}: {rule: UptimeAlert}) { 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 every [interval]', { + return tct('Expected [interval]', { interval: scheduleAsText(rule.config), }); } From 69360593ef5eced71ee9e79edefea990a3022b26 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Tue, 21 Jan 2025 13:08:04 -0800 Subject: [PATCH 68/74] chore(settings): Remove early adopter banner on CSP settings page (#83772) --- static/app/views/settings/projectSecurityHeaders/csp.tsx | 3 --- 1 file changed, 3 deletions(-) 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() { /> - - Date: Tue, 21 Jan 2025 16:10:43 -0500 Subject: [PATCH 69/74] feat(dashboards-eap): Use RPC by default (#83757) Explore will ship with RPC enabled by default, so enable dashboards with it as well --- .../views/dashboards/datasetConfig/spans.tsx | 9 ++--- .../components/newWidgetBuilder.tsx | 12 ------- .../widgetBuilder/components/rpcToggle.tsx | 36 ------------------- .../components/widgetBuilderSlideout.tsx | 10 ------ 4 files changed, 2 insertions(+), 65 deletions(-) delete mode 100644 static/app/views/dashboards/widgetBuilder/components/rpcToggle.tsx 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/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({ ) : ( ({ - 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/widgetBuilderSlideout.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx index 02379254ac26c3..4b974346e4169d 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx @@ -11,7 +11,6 @@ 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 { @@ -19,7 +18,6 @@ import { 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'; @@ -30,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'; @@ -69,7 +66,6 @@ function WidgetBuilderSlideout({ onDataFetched, thresholdMetaState, }: WidgetBuilderSlideoutProps) { - const organization = useOrganization(); const {state} = useWidgetBuilderContext(); const [initialState] = useState(state); const [error, setError] = useState>({}); @@ -154,12 +150,6 @@ function WidgetBuilderSlideout({
- {organization.features.includes('visibility-explore-dataset') && - state.dataset === WidgetType.SPANS && ( -
- -
- )}
From 03acc2dd647150ecd149ccde9b6273cf52ef3659 Mon Sep 17 00:00:00 2001 From: Hubert Deng Date: Tue, 21 Jan 2025 13:36:28 -0800 Subject: [PATCH 70/74] chore(devservices): Bump devservices to 1.0.10 (#83752) picks up https://github.com/getsentry/devservices/releases/tag/1.0.10 --- requirements-dev-frozen.txt | 2 +- requirements-dev.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev-frozen.txt b/requirements-dev-frozen.txt index 8625e98f40b8fc..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 diff --git a/requirements-dev.txt b/requirements-dev.txt index b80775f9254a36..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 From ea8e0b33bc6c2ef56af507f424158e9ca5b23d2e Mon Sep 17 00:00:00 2001 From: anthony sottile <103459774+asottile-sentry@users.noreply.github.com> Date: Tue, 21 Jan 2025 16:43:27 -0500 Subject: [PATCH 71/74] ref: remove unused race-free-group-creation feature + option (#83773) so I can clean up the related code in `getsentry` --- src/sentry/api/serializers/models/project.py | 1 - src/sentry/apidocs/examples/project_examples.py | 1 - src/sentry/apidocs/examples/team_examples.py | 4 ---- src/sentry/features/temporary.py | 2 -- src/sentry/options/defaults.py | 4 ---- 5 files changed, 12 deletions(-) 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/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/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/options/defaults.py b/src/sentry/options/defaults.py index efe39d34ffcae8..133eb8cf02be76 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -759,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", From a4ddba7e0ee004414dc09a04ae14ba5350dabcbd Mon Sep 17 00:00:00 2001 From: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com> Date: Tue, 21 Jan 2025 14:01:15 -0800 Subject: [PATCH 72/74] ref(replay): add banner to mobile request/response network tab (#83769) update the mobile replay network tab request & response tabs to show a blue banner: https://github.com/user-attachments/assets/44583667-06bd-4eb9-abf2-ca9fa16468a6 also updated some margins for consistency closes https://github.com/getsentry/sentry/issues/83739 --- .../detail/network/details/components.tsx | 3 ++- .../detail/network/details/onboarding.tsx | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) 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 + ) : ( Date: Tue, 21 Jan 2025 22:05:04 +0000 Subject: [PATCH 73/74] chore(deps): bump undici from 5.28.4 to 5.28.5 (#83776) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0b8a1666059afa..a3b2c6154a3982 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12064,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" From 5cc8447ccaac369ea63ea6c281600cc9606f8adb Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Tue, 21 Jan 2025 13:16:52 -0800 Subject: [PATCH 74/74] :bug: fix: fix uptime issue threads --- .../integrations/slack/actions/notification.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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)