import {EventsStatsFixture} from 'sentry-fixture/events'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {initializeOrg} from 'sentry-test/initializeOrg'; import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import type {PageFilters} from 'sentry/types/core'; import {MetricsResultsMetaProvider} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext'; import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; import {DashboardFilterKeys, DisplayType} from 'sentry/views/dashboards/types'; import { DashboardsMEPContext, DashboardsMEPProvider, } from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext'; import type {GenericWidgetQueriesChildrenProps} from 'sentry/views/dashboards/widgetCard/genericWidgetQueries'; import WidgetQueries, { flattenMultiSeriesDataWithGrouping, } from 'sentry/views/dashboards/widgetCard/widgetQueries'; describe('Dashboards > WidgetQueries', function () { const initialData = initializeOrg(); const renderWithProviders = component => render( {component} ); const multipleQueryWidget = { title: 'Errors', interval: '5m', displayType: DisplayType.LINE, queries: [ { conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], name: 'errors', orderby: '', }, { conditions: 'event.type:default', fields: ['count()'], aggregates: ['count()'], columns: [], name: 'default', orderby: '', }, ], }; const singleQueryWidget = { title: 'Errors', interval: '5m', displayType: DisplayType.LINE, queries: [ { conditions: 'event.type:error', fields: ['count()'], aggregates: ['count()'], columns: [], name: 'errors', orderby: '', }, ], }; const tableWidget = { title: 'SDK', interval: '5m', displayType: DisplayType.TABLE, queries: [ { conditions: 'event.type:error', fields: ['sdk.name'], aggregates: [], columns: ['sdk.name'], name: 'sdk', orderby: '', }, ], }; const selection: PageFilters = { projects: [1], environments: ['prod'], datetime: { period: '14d', start: null, end: null, utc: false, }, }; afterEach(function () { MockApiClient.clearMockResponses(); }); it('can send multiple API requests', async function () { const errorMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], match: [MockApiClient.matchQuery({query: 'event.type:error'})], }); const defaultMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], match: [MockApiClient.matchQuery({query: 'event.type:default'})], }); renderWithProviders( {() =>
} ); // Child should be rendered and 2 requests should be sent. await screen.findByTestId('child'); expect(errorMock).toHaveBeenCalledTimes(1); expect(defaultMock).toHaveBeenCalledTimes(1); }); it('appends dashboard filters to events series request', async function () { const mock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], }); renderWithProviders( {() =>
} ); await screen.findByTestId('child'); expect(mock).toHaveBeenCalledWith( '/organizations/org-slug/events-stats/', expect.objectContaining({ query: expect.objectContaining({ query: '(event.type:error) release:["abc@1.2.0","abc@1.3.0"] ', }), }) ); }); it('appends dashboard filters to events table request', async function () { const mock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events/', body: [], }); renderWithProviders( {() =>
} ); await screen.findByTestId('child'); expect(mock).toHaveBeenCalledWith( '/organizations/org-slug/events/', expect.objectContaining({ query: expect.objectContaining({ query: '(event.type:error) release:"abc@1.3.0" ', }), }) ); }); it('sets errorMessage when the first request fails', async function () { const okMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', match: [MockApiClient.matchQuery({query: 'event.type:error'})], body: [], }); const failMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', statusCode: 400, body: {detail: 'Bad request data'}, match: [MockApiClient.matchQuery({query: 'event.type:default'})], }); let error: string | undefined; renderWithProviders( {({errorMessage}: {errorMessage?: string}) => { error = errorMessage; return
; }} ); // Child should be rendered and 2 requests should be sent. expect(await screen.findByTestId('child')).toBeInTheDocument(); await waitFor(() => { expect(error).toEqual('Bad request data'); }); expect(okMock).toHaveBeenCalledTimes(1); expect(failMock).toHaveBeenCalledTimes(1); }); it('adjusts interval based on date window', async function () { const errorMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], }); const widget = {...singleQueryWidget, interval: '1m'}; const longSelection: PageFilters = { projects: [1], environments: ['prod', 'dev'], datetime: { period: '90d', start: null, end: null, utc: false, }, }; renderWithProviders( {() =>
} ); // Child should be rendered and interval bumped up. await screen.findByTestId('child'); expect(errorMock).toHaveBeenCalledTimes(1); expect(errorMock).toHaveBeenCalledWith( '/organizations/org-slug/events-stats/', expect.objectContaining({ query: expect.objectContaining({ interval: '4h', statsPeriod: '90d', environment: ['prod', 'dev'], project: [1], }), }) ); }); it('adjusts interval based on date window 14d', async function () { const errorMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], }); const widget = {...singleQueryWidget, interval: '1m'}; renderWithProviders( {() =>
} ); // Child should be rendered and interval bumped up. await screen.findByTestId('child'); expect(errorMock).toHaveBeenCalledTimes(1); expect(errorMock).toHaveBeenCalledWith( '/organizations/org-slug/events-stats/', expect.objectContaining({ query: expect.objectContaining({interval: '30m'}), }) ); }); it('can send table result queries', async function () { const tableMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events/', body: { meta: {'sdk.name': 'string'}, data: [{'sdk.name': 'python'}], }, }); let childProps: GenericWidgetQueriesChildrenProps | undefined; renderWithProviders( {props => { childProps = props; return
; }} ); // Child should be rendered and 1 requests should be sent. await screen.findByTestId('child'); expect(tableMock).toHaveBeenCalledTimes(1); expect(tableMock).toHaveBeenCalledWith( '/organizations/org-slug/events/', expect.objectContaining({ query: expect.objectContaining({ query: 'event.type:error', field: 'sdk.name', statsPeriod: '14d', environment: 'prod', project: '1', }), }) ); expect(childProps?.timeseriesResults).toBeUndefined(); await waitFor(() => expect(childProps?.tableResults?.[0].data).toHaveLength(1)); expect(childProps?.tableResults?.[0].meta).toBeDefined(); }); it('can send multiple table queries', async function () { const firstQuery = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events/', body: { meta: {'sdk.name': 'string'}, data: [{'sdk.name': 'python'}], }, match: [MockApiClient.matchQuery({query: 'event.type:error'})], }); const secondQuery = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events/', body: { meta: {title: 'string'}, data: [{title: 'ValueError'}], }, match: [MockApiClient.matchQuery({query: 'title:ValueError'})], }); const widget = { title: 'SDK', interval: '5m', displayType: DisplayType.TABLE, queries: [ { conditions: 'event.type:error', fields: ['sdk.name'], aggregates: [], columns: ['sdk.name'], name: 'sdk', orderby: '', }, { conditions: 'title:ValueError', fields: ['title'], aggregates: [], columns: ['sdk.name'], name: 'title', orderby: '', }, ], }; let childProps: GenericWidgetQueriesChildrenProps | undefined; renderWithProviders( {props => { childProps = props; return
; }} ); // Child should be rendered and 2 requests should be sent. await screen.findByTestId('child'); expect(firstQuery).toHaveBeenCalledTimes(1); expect(secondQuery).toHaveBeenCalledTimes(1); await waitFor(() => expect(childProps?.tableResults).toHaveLength(2)); expect(childProps?.tableResults?.[0].data[0]['sdk.name']).toBeDefined(); expect(childProps?.tableResults?.[1].data[0].title).toBeDefined(); }); it('can send big number result queries', async function () { const tableMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events/', body: { meta: {'sdk.name': 'string'}, data: [{'sdk.name': 'python'}], }, }); let childProps: GenericWidgetQueriesChildrenProps | undefined; renderWithProviders( {props => { childProps = props; return
; }} ); // Child should be rendered and 1 requests should be sent. await screen.findByTestId('child'); expect(tableMock).toHaveBeenCalledTimes(1); expect(tableMock).toHaveBeenCalledWith( '/organizations/org-slug/events/', expect.objectContaining({ query: expect.objectContaining({ referrer: 'api.dashboards.bignumberwidget', query: 'event.type:error', field: 'sdk.name', statsPeriod: '14d', environment: 'prod', project: '1', }), }) ); expect(childProps?.timeseriesResults).toBeUndefined(); await waitFor(() => expect(childProps?.tableResults?.[0]?.data).toHaveLength(1)); expect(childProps?.tableResults?.[0]?.meta).toBeDefined(); }); it('stops loading state once all queries finish even if some fail', async function () { const firstQuery = MockApiClient.addMockResponse({ statusCode: 500, url: '/organizations/org-slug/events/', body: {detail: 'it didnt work'}, match: [MockApiClient.matchQuery({query: 'event.type:error'})], }); const secondQuery = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events/', body: { meta: {title: 'string'}, data: [{title: 'ValueError'}], }, match: [MockApiClient.matchQuery({query: 'title:ValueError'})], }); const widget = { title: 'SDK', interval: '5m', displayType: DisplayType.TABLE, queries: [ { conditions: 'event.type:error', fields: ['sdk.name'], aggregates: [], columns: ['sdk.name'], name: 'sdk', orderby: '', }, { conditions: 'title:ValueError', fields: ['sdk.name'], aggregates: [], columns: ['sdk.name'], name: 'title', orderby: '', }, ], }; let childProps: GenericWidgetQueriesChildrenProps | undefined; renderWithProviders( {props => { childProps = props; return
; }} ); // Child should be rendered and 2 requests should be sent. await screen.findByTestId('child'); expect(firstQuery).toHaveBeenCalledTimes(1); expect(secondQuery).toHaveBeenCalledTimes(1); await waitFor(() => expect(childProps?.loading).toEqual(false)); }); it('sets bar charts to 1d interval', async function () { const errorMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], match: [MockApiClient.matchQuery({interval: '1d'})], }); const barWidget = { ...singleQueryWidget, displayType: DisplayType.BAR, // Should be ignored for bars. interval: '5m', }; renderWithProviders( {() =>
} ); // Child should be rendered and 1 requests should be sent. await screen.findByTestId('child'); expect(errorMock).toHaveBeenCalledTimes(1); }); it('returns timeseriesResults in the same order as widgetQuery', async function () { MockApiClient.clearMockResponses(); const defaultMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', method: 'GET', body: { data: [ [ 1000, [ { count: 100, }, ], ], ], start: 1000, end: 2000, }, match: [MockApiClient.matchQuery({query: 'event.type:default'})], }); const errorMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', method: 'GET', body: { data: [ [ 1000, [ { count: 200, }, ], ], ], start: 1000, end: 2000, }, match: [MockApiClient.matchQuery({query: 'event.type:error'})], }); const barWidget = { ...multipleQueryWidget, displayType: DisplayType.BAR, // Should be ignored for bars. interval: '5m', }; const child = jest.fn(() =>
); renderWithProviders( {child} ); await screen.findByTestId('child'); expect(defaultMock).toHaveBeenCalledTimes(1); expect(errorMock).toHaveBeenCalledTimes(1); await waitFor(() => expect(child).toHaveBeenLastCalledWith( expect.objectContaining({ timeseriesResults: [ {data: [{name: 1000000, value: 200}], seriesName: 'errors : count()'}, {data: [{name: 1000000, value: 100}], seriesName: 'default : count()'}, ], }) ) ); }); it('calls events-stats with 4h interval when interval buckets would exceed 66', async function () { const eventsStatsMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], }); const areaWidget = { ...singleQueryWidget, displayType: DisplayType.AREA, interval: '5m', }; renderWithProviders( {() =>
} ); // Child should be rendered and 1 requests should be sent. await screen.findByTestId('child'); expect(eventsStatsMock).toHaveBeenCalledTimes(1); expect(eventsStatsMock).toHaveBeenCalledWith( '/organizations/org-slug/events-stats/', expect.objectContaining({query: expect.objectContaining({interval: '4h'})}) ); }); it('does not re-query events and sets name in widgets', async function () { const eventsStatsMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: EventsStatsFixture(), }); const lineWidget = { ...singleQueryWidget, displayType: DisplayType.LINE, interval: '5m', }; let childProps; const {rerender} = renderWithProviders( {props => { childProps = props; return
; }} ); expect(eventsStatsMock).toHaveBeenCalledTimes(1); await waitFor(() => expect(childProps.loading).toEqual(false)); // Simulate a re-render with a new query alias rerender( {props => { childProps = props; return
; }} ); // Did not re-query expect(eventsStatsMock).toHaveBeenCalledTimes(1); expect(childProps.timeseriesResults[0].seriesName).toEqual( 'this query alias changed : count()' ); }); describe('multi-series grouped data', () => { const [START, END] = [1647399900, 1647399901]; let mockCountData, mockCountUniqueData, mockRawResultData; beforeEach(() => { mockCountData = { start: START, end: END, data: [ [START, [{'count()': 0}]], [END, [{'count()': 0}]], ], }; mockCountUniqueData = { start: START, end: END, data: [ [START, [{'count_unique()': 0}]], [END, [{'count_unique()': 0}]], ], }; mockRawResultData = { local: { 'count()': mockCountData, 'count_unique()': mockCountUniqueData, order: 0, }, prod: { 'count()': mockCountData, 'count_unique()': mockCountUniqueData, order: 1, }, }; }); it('combines group name and aggregate names in grouped multi series data', () => { const actual = flattenMultiSeriesDataWithGrouping(mockRawResultData, ''); expect(actual).toEqual([ [ 0, expect.objectContaining({ seriesName: 'local : count()', data: expect.anything(), }), ], [ 0, expect.objectContaining({ seriesName: 'local : count_unique()', data: expect.anything(), }), ], [ 1, expect.objectContaining({ seriesName: 'prod : count()', data: expect.anything(), }), ], [ 1, expect.objectContaining({ seriesName: 'prod : count_unique()', data: expect.anything(), }), ], ]); }); it('prefixes with a query alias when provided', () => { const actual = flattenMultiSeriesDataWithGrouping(mockRawResultData, 'Query 1'); expect(actual).toEqual([ [ 0, expect.objectContaining({ seriesName: 'Query 1 > local : count()', data: expect.anything(), }), ], [ 0, expect.objectContaining({ seriesName: 'Query 1 > local : count_unique()', data: expect.anything(), }), ], [ 1, expect.objectContaining({ seriesName: 'Query 1 > prod : count()', data: expect.anything(), }), ], [ 1, expect.objectContaining({ seriesName: 'Query 1 > prod : count_unique()', data: expect.anything(), }), ], ]); }); }); it('charts send metricsEnhanced requests', async function () { const {organization} = initialData; const mock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: { data: [ [ 1000, [ { count: 100, }, ], ], ], isMetricsData: false, start: 1000, end: 2000, }, }); const setIsMetricsMock = jest.fn(); const children = jest.fn(() =>
); renderWithProviders( {children} ); expect(mock).toHaveBeenCalledWith( '/organizations/org-slug/events-stats/', expect.objectContaining({ query: expect.objectContaining({dataset: 'metricsEnhanced'}), }) ); await waitFor(() => { expect(setIsMetricsMock).toHaveBeenCalledWith(false); }); }); it('tables send metricsEnhanced requests', async function () { const {organization} = initialData; const mock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events/', body: { meta: {title: 'string', isMetricsData: true}, data: [{title: 'ValueError'}], }, }); const setIsMetricsMock = jest.fn(); const children = jest.fn(() =>
); renderWithProviders( {children} ); expect(mock).toHaveBeenCalledWith( '/organizations/org-slug/events/', expect.objectContaining({ query: expect.objectContaining({dataset: 'metricsEnhanced'}), }) ); await waitFor(() => { expect(setIsMetricsMock).toHaveBeenCalledWith(true); }); }); it('does not inject equation aliases for top N requests', async function () { const testData = initializeOrg({ organization: { ...OrganizationFixture(), }, }); const eventsStatsMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', body: [], }); const areaWidget = { title: 'Errors', displayType: DisplayType.AREA, interval: '5m', queries: [ { conditions: 'event.type:error', fields: [], aggregates: ['count()', 'equation|count() * 2'], columns: ['project'], orderby: 'equation[0]', name: '', }, ], }; renderWithProviders( {() =>
} ); // Child should be rendered and 1 requests should be sent. await screen.findByTestId('child'); expect(eventsStatsMock).toHaveBeenCalledTimes(1); expect(eventsStatsMock).toHaveBeenCalledWith( '/organizations/org-slug/events-stats/', expect.objectContaining({ query: expect.objectContaining({ field: ['project', 'count()', 'equation|count() * 2'], orderby: 'equation[0]', }), }) ); }); });