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'