From 61ae2f4a71488932abb5a95e026fd9e719a154eb Mon Sep 17 00:00:00 2001 From: Ash <0Calories@users.noreply.github.com> Date: Tue, 27 Aug 2024 17:08:53 -0400 Subject: [PATCH] feat(insights): Database system selector in Queries module (#75942) Adds a new dropdown selector to the Queries module that is responsible for changing the view depending on the currently selected database system, While the component itself is simple, there is a lot of logic happening behind the scenes that is difficult to follow, here it is broken down; - Queries the current project's spans to finds all database systems being used, and populates the dropdown selector options with compatible systems, in descending order based on number of spans per system - defaults to choosing the first option in the list (the system with the most spans) - Component is disabled when only one database system is being used (consistent with our design philosophy to not hide information / components) - The most recent option that was selected is saved to `localStorage`, so next time you view the page it will maintain your selection - The currently selected system is saved in the URL's query params - Following a URL with a non-default system selected will maintain this selection, but will not override your selection that is saved to your localStorage **TODO in followup PR** - [ ] Update the view according to which system was selected (mongodb terminology and formatting will be different from SQL) ![image](https://github.com/user-attachments/assets/25c1dfe5-b98c-4be1-b04a-e1a4a219cee7) --- .../databaseSystemSelector.spec.tsx | 234 ++++++++++++++++++ .../components/databaseSystemSelector.tsx | 33 +++ .../components/useSystemSelectorOptions.tsx | 71 ++++++ .../database/views/databaseLandingPage.tsx | 15 +- static/app/views/insights/types.tsx | 2 + 5 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 static/app/views/insights/database/components/databaseSystemSelector.spec.tsx create mode 100644 static/app/views/insights/database/components/databaseSystemSelector.tsx create mode 100644 static/app/views/insights/database/components/useSystemSelectorOptions.tsx diff --git a/static/app/views/insights/database/components/databaseSystemSelector.spec.tsx b/static/app/views/insights/database/components/databaseSystemSelector.spec.tsx new file mode 100644 index 00000000000000..62a2c19d89a695 --- /dev/null +++ b/static/app/views/insights/database/components/databaseSystemSelector.spec.tsx @@ -0,0 +1,234 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {useSpanMetrics} from 'sentry/views/insights/common/queries/useDiscover'; +import {DatabaseSystemSelector} from 'sentry/views/insights/database/components/databaseSystemSelector'; +import {SpanMetricsField} from 'sentry/views/insights/types'; + +jest.mock('sentry/views/insights/common/queries/useDiscover', () => ({ + useSpanMetrics: jest.fn(), +})); + +jest.mock('sentry/utils/useLocalStorageState', () => ({ + useLocalStorageState: jest.fn(), +})); + +jest.mock('sentry/utils/useLocation', () => ({ + useLocation: jest.fn(), +})); + +jest.mock('sentry/utils/useNavigate', () => ({ + useNavigate: jest.fn(), +})); + +const mockUseLocalStorageState = jest.mocked(useLocalStorageState); +const mockUseSpanMetrics = jest.mocked(useSpanMetrics); +const mockUseLocation = jest.mocked(useLocation); +const mockUseNavigate = jest.mocked(useNavigate); + +describe('DatabaseSystemSelector', function () { + const organization = OrganizationFixture(); + + afterAll(() => { + jest.clearAllMocks(); + }); + + beforeEach(() => { + mockUseLocation.mockReturnValue({ + query: {project: ['1']}, + pathname: '', + search: '', + hash: '', + state: undefined, + action: 'POP', + key: '', + }); + }); + + it('is disabled and does not select a system if there are none available', async function () { + const mockSetState = jest.fn(); + mockUseLocalStorageState.mockReturnValue(['', mockSetState]); + mockUseSpanMetrics.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + } as any); + + render(, {organization}); + + expect(mockSetState).not.toHaveBeenCalled(); + const dropdownButton = await screen.findByRole('button'); + expect(dropdownButton).toBeInTheDocument(); + expect(dropdownButton).toHaveTextContent('DB SystemNone'); + }); + + it('is disabled when only one database system is present and shows that system as selected', async function () { + const mockSetState = jest.fn(); + mockUseLocalStorageState.mockReturnValue(['', mockSetState]); + mockUseSpanMetrics.mockReturnValue({ + data: [ + { + 'span.system': 'postgresql', + 'count()': 1000, + }, + ], + isLoading: false, + isError: false, + } as any); + + render(, {organization}); + + const dropdownSelector = await screen.findByRole('button'); + expect(dropdownSelector).toBeDisabled(); + expect(mockSetState).toHaveBeenCalledWith('postgresql'); + }); + + it('renders all database system options correctly', async function () { + mockUseSpanMetrics.mockReturnValue({ + data: [ + { + 'span.system': 'postgresql', + 'count()': 1000, + }, + { + 'span.system': 'mongodb', + 'count()': 500, + }, + { + 'span.system': 'chungusdb', + 'count()': 200, + }, + ], + isLoading: false, + isError: false, + } as any); + + render(, {organization}); + + const dropdownSelector = await screen.findByRole('button'); + expect(dropdownSelector).toBeEnabled(); + expect(mockUseSpanMetrics).toHaveBeenCalled(); + + const dropdownButton = await screen.findByRole('button'); + expect(dropdownButton).toBeInTheDocument(); + + await userEvent.click(dropdownButton); + + const dropdownOptionLabels = await screen.findAllByTestId('menu-list-item-label'); + expect(dropdownOptionLabels[0]).toHaveTextContent('PostgreSQL'); + expect(dropdownOptionLabels[1]).toHaveTextContent('MongoDB'); + // chungusdb does not exist, so we do not expect this option to have casing + expect(dropdownOptionLabels[2]).toHaveTextContent('chungusdb'); + }); + + it('chooses the currently selected system from localStorage', async function () { + mockUseLocalStorageState.mockReturnValue(['mongodb', () => {}]); + mockUseSpanMetrics.mockReturnValue({ + data: [ + { + 'span.system': 'postgresql', + 'count()': 1000, + }, + { + 'span.system': 'mongodb', + 'count()': 500, + }, + { + 'span.system': 'chungusdb', + 'count()': 200, + }, + ], + isLoading: false, + isError: false, + } as any); + + render(, {organization}); + + expect(await screen.findByText('MongoDB')).toBeInTheDocument(); + }); + + it('does not set the value from localStorage if the value is invalid', async function () { + const mockSetState = jest.fn(); + mockUseLocalStorageState.mockReturnValue(['chungusdb', mockSetState]); + mockUseSpanMetrics.mockReturnValue({ + data: [ + { + 'span.system': 'postgresql', + 'count()': 1000, + }, + ], + isLoading: false, + isError: false, + } as any); + + render(, {organization}); + + const dropdownSelector = await screen.findByRole('button'); + expect(dropdownSelector).toBeInTheDocument(); + expect(mockSetState).not.toHaveBeenCalledWith('chungusdb'); + }); + + it('prioritizes the system set in query parameters but does not replace localStorage value until an option is clicked', async function () { + const {SPAN_SYSTEM} = SpanMetricsField; + const mockNavigate = jest.fn(); + mockUseNavigate.mockReturnValue(mockNavigate); + + mockUseLocation.mockReturnValue({ + query: {project: ['1'], [SPAN_SYSTEM]: 'mongodb'}, + pathname: '', + search: '', + hash: '', + state: undefined, + action: 'POP', + key: '', + }); + + mockUseSpanMetrics.mockReturnValue({ + data: [ + { + 'span.system': 'postgresql', + 'count()': 1000, + }, + { + 'span.system': 'mongodb', + 'count()': 500, + }, + ], + isLoading: false, + isError: false, + } as any); + + const mockSetState = jest.fn(); + mockUseLocalStorageState.mockReturnValue(['postgresql', mockSetState]); + + render(, {organization}); + + const dropdownSelector = await screen.findByRole('button'); + expect(dropdownSelector).toHaveTextContent('DB SystemMongoDB'); + expect(mockSetState).not.toHaveBeenCalledWith('mongodb'); + + // Now that it has been confirmed that following a URL does not reset localStorage state, confirm that + // clicking a different option will update both the state and the URL + await userEvent.click(dropdownSelector); + const dropdownOptionLabels = await screen.findAllByTestId('menu-list-item-label'); + expect(dropdownOptionLabels[0]).toHaveTextContent('PostgreSQL'); + expect(dropdownOptionLabels[1]).toHaveTextContent('MongoDB'); + + await userEvent.click(dropdownOptionLabels[0]); + expect(dropdownSelector).toHaveTextContent('DB SystemPostgreSQL'); + expect(mockSetState).toHaveBeenCalledWith('postgresql'); + expect(mockNavigate).toHaveBeenCalledWith({ + action: 'POP', + hash: '', + key: '', + pathname: '', + query: {project: ['1'], 'span.system': 'postgresql'}, + search: '', + state: undefined, + }); + }); +}); diff --git a/static/app/views/insights/database/components/databaseSystemSelector.tsx b/static/app/views/insights/database/components/databaseSystemSelector.tsx new file mode 100644 index 00000000000000..a5d0bc53d3d46b --- /dev/null +++ b/static/app/views/insights/database/components/databaseSystemSelector.tsx @@ -0,0 +1,33 @@ +import {CompactSelect} from 'sentry/components/compactSelect'; +import {t} from 'sentry/locale'; +import {decodeScalar} from 'sentry/utils/queryString'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {useSystemSelectorOptions} from 'sentry/views/insights/database/components/useSystemSelectorOptions'; +import {SpanMetricsField} from 'sentry/views/insights/types'; + +const {SPAN_SYSTEM} = SpanMetricsField; + +export function DatabaseSystemSelector() { + const location = useLocation(); + const navigate = useNavigate(); + + // If there is no query parameter for the system, retrieve the current value from the hook instead + const systemQueryParam = decodeScalar(location.query?.[SPAN_SYSTEM]); + const {selectedSystem, setSelectedSystem, options, isLoading, isError} = + useSystemSelectorOptions(); + + return ( + { + setSelectedSystem(option.value); + navigate({...location, query: {...location.query, [SPAN_SYSTEM]: option.value}}); + }} + options={options} + triggerProps={{prefix: t('DB System')}} + loading={isLoading} + disabled={isError || isLoading || options.length <= 1} + value={systemQueryParam ?? selectedSystem} + /> + ); +} diff --git a/static/app/views/insights/database/components/useSystemSelectorOptions.tsx b/static/app/views/insights/database/components/useSystemSelectorOptions.tsx new file mode 100644 index 00000000000000..4d3dba9dc56e9b --- /dev/null +++ b/static/app/views/insights/database/components/useSystemSelectorOptions.tsx @@ -0,0 +1,71 @@ +import type {SelectOption} from 'sentry/components/compactSelect'; +import {MutableSearch} from 'sentry/utils/tokenizeSearch'; +import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; +import {useSpanMetrics} from 'sentry/views/insights/common/queries/useDiscover'; +import {SpanMetricsField} from 'sentry/views/insights/types'; + +/** + * The supported relational database system values are based on what is + * set in the Sentry Python SDK. The only currently supported NoSQL DBMS is MongoDB. + * + * https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/sqlalchemy.py#L125 + */ +enum SupportedDatabaseSystems { + // SQL + SQLITE = 'sqlite', + POSTGRESQL = 'postgresql', + MARIADB = 'mariadb', + MYSQL = 'mysql', + ORACLE = 'oracle', + // NoSQL + MONGODB = 'mongodb', +} + +const DATABASE_SYSTEM_TO_LABEL: Record = { + [SupportedDatabaseSystems.SQLITE]: 'SQLite', + [SupportedDatabaseSystems.POSTGRESQL]: 'PostgreSQL', + [SupportedDatabaseSystems.MARIADB]: 'MariaDB', + [SupportedDatabaseSystems.MYSQL]: 'MySQL', + [SupportedDatabaseSystems.ORACLE]: 'Oracle', + [SupportedDatabaseSystems.MONGODB]: 'MongoDB', +}; + +export function useSystemSelectorOptions() { + const [selectedSystem, setSelectedSystem] = useLocalStorageState( + 'insights-db-system-selector', + '' + ); + + const {data, isLoading, isError} = useSpanMetrics( + { + search: MutableSearch.fromQueryObject({'span.op': 'db'}), + + fields: [SpanMetricsField.SPAN_SYSTEM, 'count()'], + sorts: [{field: 'count()', kind: 'desc'}], + }, + 'api.starfish.database-system-selector' + ); + + const options: SelectOption[] = []; + data.forEach(entry => { + const system = entry['span.system']; + if (system) { + const label: string = + system in DATABASE_SYSTEM_TO_LABEL ? DATABASE_SYSTEM_TO_LABEL[system] : system; + + options.push({value: system, label, textValue: label}); + } + }); + + // Edge case: Invalid DB system was retrieved from localStorage + if (!options.find(option => selectedSystem === option.value) && options.length > 0) { + setSelectedSystem(options[0].value); + } + + // Edge case: No current system is saved in localStorage + if (!selectedSystem && options.length > 0) { + setSelectedSystem(options[0].value); + } + + return {selectedSystem, setSelectedSystem, options, isLoading, isError}; +} diff --git a/static/app/views/insights/database/views/databaseLandingPage.tsx b/static/app/views/insights/database/views/databaseLandingPage.tsx index 86f63af637d088..d972da2ffb401b 100644 --- a/static/app/views/insights/database/views/databaseLandingPage.tsx +++ b/static/app/views/insights/database/views/databaseLandingPage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import styled from '@emotion/styled'; import Alert from 'sentry/components/alert'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; @@ -8,6 +9,7 @@ import * as Layout from 'sentry/components/layouts/thirds'; import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip'; import SearchBar from 'sentry/components/searchBar'; import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; import {trackAnalytics} from 'sentry/utils/analytics'; import {browserHistory} from 'sentry/utils/browserHistory'; import {decodeScalar, decodeSorts} from 'sentry/utils/queryString'; @@ -30,6 +32,7 @@ import {ActionSelector} from 'sentry/views/insights/common/views/spans/selectors import {DomainSelector} from 'sentry/views/insights/common/views/spans/selectors/domainSelector'; import {DurationChart} from 'sentry/views/insights/database/components/charts/durationChart'; import {ThroughputChart} from 'sentry/views/insights/database/components/charts/throughputChart'; +import {DatabaseSystemSelector} from 'sentry/views/insights/database/components/databaseSystemSelector'; import {NoDataMessage} from 'sentry/views/insights/database/components/noDataMessage'; import { isAValidSort, @@ -179,7 +182,12 @@ export function DatabaseLandingPage() { )} - + + + {organization.features.includes( + 'performance-queries-mongodb-extraction' + ) && } + @@ -251,4 +259,9 @@ function PageWithProviders() { ); } +const PageFilterWrapper = styled('div')` + display: flex; + gap: ${space(3)}; +`; + export default PageWithProviders; diff --git a/static/app/views/insights/types.tsx b/static/app/views/insights/types.tsx index 3387eb2fb682c8..114481fd919226 100644 --- a/static/app/views/insights/types.tsx +++ b/static/app/views/insights/types.tsx @@ -28,6 +28,7 @@ export enum SpanMetricsField { SPAN_GROUP = 'span.group', SPAN_DURATION = 'span.duration', SPAN_SELF_TIME = 'span.self_time', + SPAN_SYSTEM = 'span.system', PROJECT = 'project', PROJECT_ID = 'project.id', TRANSACTION = 'transaction', @@ -78,6 +79,7 @@ export type SpanStringFields = | 'span.action' | 'span.group' | 'span.category' + | 'span.system' | 'transaction' | 'transaction.method' | 'release'