Browse Source

ref(perf): Add query batching for landing v3 widgets (#30027)

* ref(perf): Add query batching for landing v3 widgets

This adds a first pass at batching for the mini chart widgets at the top of the new landing v3.
Kev 3 years ago
parent
commit
24afbe921e

+ 13 - 2
static/app/actionCreators/events.tsx

@@ -12,6 +12,7 @@ import {
 import {LocationQuery} from 'app/utils/discover/eventView';
 import {getPeriod} from 'app/utils/getPeriod';
 import {PERFORMANCE_URL_PARAM} from 'app/utils/performance/constants';
+import {QueryBatching} from 'app/utils/performance/contexts/genericQueryBatcher';
 
 type Options = {
   organization: OrganizationSummary;
@@ -33,6 +34,7 @@ type Options = {
   partial: boolean;
   withoutZerofill?: boolean;
   referrer?: string;
+  queryBatching?: QueryBatching;
 };
 
 /**
@@ -50,6 +52,7 @@ type Options = {
  * @param {Boolean} options.includePrevious Should request also return reqsults for previous period?
  * @param {Number} options.limit The number of rows to return
  * @param {String} options.query Search query
+ * @param {QueryBatching} options.queryBatching A container for batching functions from a provider
  */
 export const doEventsRequest = (
   api: Client,
@@ -72,6 +75,7 @@ export const doEventsRequest = (
     partial,
     withoutZerofill,
     referrer,
+    queryBatching,
   }: Options
 ): Promise<EventsStats | MultiSeriesEventsStats> => {
   const shouldDoublePeriod = canIncludePreviousPeriod(includePrevious, period);
@@ -98,12 +102,19 @@ export const doEventsRequest = (
   // the tradeoff for now.
   const periodObj = getPeriod({period, start, end}, {shouldDoublePeriod});
 
-  return api.requestPromise(`/organizations/${organization.slug}/events-stats/`, {
+  const queryObject = {
     query: {
       ...urlQuery,
       ...periodObj,
     },
-  });
+  };
+  const pathname = `/organizations/${organization.slug}/events-stats/`;
+
+  if (queryBatching?.batchRequest) {
+    return queryBatching.batchRequest(api, pathname, queryObject);
+  }
+
+  return api.requestPromise(pathname, queryObject);
 };
 
 export type EventQuery = {

+ 6 - 1
static/app/components/charts/eventsRequest.tsx

@@ -17,6 +17,7 @@ import {
 } from 'app/types';
 import {Series, SeriesDataUnit} from 'app/types/echarts';
 import {stripEquationPrefix} from 'app/utils/discover/fields';
+import {QueryBatching} from 'app/utils/performance/contexts/genericQueryBatcher';
 
 export type TimeSeriesData = {
   // timeseries data
@@ -174,6 +175,10 @@ type EventsRequestPartialProps = {
    * A unique name for what's triggering this request, see organization_events_stats for an allowlist
    */
   referrer?: string;
+  /**
+   * A container for query batching data and functions.
+   */
+  queryBatching?: QueryBatching;
 };
 
 type TimeAggregationProps =
@@ -192,7 +197,7 @@ type EventsRequestState = {
   fetchedWithPrevious: boolean;
 };
 
-const propNamesToIgnore = ['api', 'children', 'organization', 'loading'];
+const propNamesToIgnore = ['api', 'children', 'organization', 'loading', 'queryBatching'];
 const omitIgnoredProps = (props: EventsRequestProps) =>
   omitBy(props, (_value, key) => propNamesToIgnore.includes(key));
 

+ 7 - 0
static/app/utils/analytics/performanceAnalyticsEvents.tsx

@@ -26,6 +26,11 @@ export type PerformanceEventParameters = {
     to_widget?: string;
     from_default?: boolean;
   };
+  'performance_views.landingv3.batch_queries': {
+    num_collected: number;
+    num_sent: number;
+    num_saved: number;
+  };
   'performance_views.overview.navigate.summary': {};
   'performance_views.overview.cellaction': {action?: string};
 };
@@ -46,4 +51,6 @@ export const performanceEventMap: Record<PerformanceEventKey, string | null> = {
     'Performance Views: Landing Widget Interaction',
   'performance_views.landingv3.widget.switch':
     'Performance Views: Landing Widget Switched',
+  'performance_views.landingv3.batch_queries':
+    'Performance Views: Landing Query Batching',
 };

+ 278 - 0
static/app/utils/performance/contexts/genericQueryBatcher.tsx

@@ -0,0 +1,278 @@
+import {createContext, Fragment, Ref, useEffect, useRef} from 'react';
+
+import {Client} from 'app/api';
+import {Organization} from 'app/types';
+import trackAdvancedAnalyticsEvent from 'app/utils/analytics/trackAdvancedAnalyticsEvent';
+import useApi from 'app/utils/useApi';
+import useOrganization from 'app/utils/useOrganization';
+
+import {createDefinedContext} from './utils';
+
+type QueryObject = {
+  query: {
+    [k: string]: any;
+  };
+}; // TODO(k-fish): Fix to ensure exact types for all requests. Simplified type for now, need to pull this in from events file.
+
+type BatchQueryDefinition = {
+  // Intermediate promise functions
+  resolve: (value: any) => void;
+  reject: (reason?: string) => void;
+
+  batchProperty: string;
+  requestQueryObject: QueryObject;
+  path: string;
+  api: Client;
+};
+
+type QueryBatch = {
+  addQuery: (q: BatchQueryDefinition, id: symbol) => void;
+};
+
+const [GenericQueryBatcherProvider, _useGenericQueryBatcher] =
+  createDefinedContext<QueryBatch>({
+    name: 'GenericQueryBatcherContext',
+  });
+
+function mergeKey(query: BatchQueryDefinition) {
+  return `${query.batchProperty}.${query.path}`;
+}
+
+type MergeMap = Record<string, BatchQueryDefinition[]>;
+
+// Builds a map that will contain an array of query definitions by mergeable key (using batch property and path)
+function queriesToMap(collectedQueries: Record<symbol, BatchQueryDefinition>) {
+  const keys = Reflect.ownKeys(collectedQueries);
+  if (!keys.length) {
+    return false;
+  }
+  const mergeMap: MergeMap = {};
+
+  keys.forEach(async key => {
+    const query = collectedQueries[key];
+    mergeMap[mergeKey(query)] = mergeMap[mergeKey(query)] || [];
+    mergeMap[mergeKey(query)].push(query);
+    delete collectedQueries[key];
+  });
+
+  return mergeMap;
+}
+
+function requestFunction(api: Client, path: string, queryObject: QueryObject) {
+  return api.requestPromise(path, queryObject);
+}
+
+function _handleUnmergeableQuery(queryDefinition: BatchQueryDefinition) {
+  const result = requestFunction(
+    queryDefinition.api,
+    queryDefinition.path,
+    queryDefinition.requestQueryObject
+  );
+  queryDefinition.resolve(result);
+}
+
+function _handleUnmergeableQueries(mergeMap: MergeMap) {
+  let queriesSent = 0;
+  Object.keys(mergeMap).forEach(async k => {
+    // Using async forEach to ensure calls start in parallel.
+    const mergeList = mergeMap[k];
+
+    if (mergeList.length === 1) {
+      const [queryDefinition] = mergeList;
+      queriesSent++;
+      _handleUnmergeableQuery(queryDefinition);
+    }
+  });
+
+  return queriesSent;
+}
+
+function _handleMergeableQueries(mergeMap: MergeMap) {
+  let queriesSent = 0;
+  Object.keys(mergeMap).forEach(async k => {
+    const mergeList = mergeMap[k];
+
+    if (mergeList.length <= 1) {
+      return;
+    }
+
+    const [exampleDefinition] = mergeList;
+    const batchProperty = exampleDefinition.batchProperty;
+    const query = {...exampleDefinition.requestQueryObject.query};
+    const requestQueryObject = {...exampleDefinition.requestQueryObject, query};
+
+    const batchValues: string[] = [];
+
+    mergeList.forEach(q => {
+      const batchFieldValue = q.requestQueryObject.query[batchProperty];
+      if (Array.isArray(batchFieldValue)) {
+        if (batchFieldValue.length > 1) {
+          // Omit multiple requests with multi fields (eg. yAxis) for now and run them as single queries
+          queriesSent++;
+          _handleUnmergeableQuery(q);
+          return;
+        }
+        // Unwrap array value if it is a single value
+        batchValues.push(batchFieldValue[0]);
+      } else {
+        batchValues.push(batchFieldValue);
+      }
+    });
+
+    requestQueryObject.query[batchProperty] = batchValues;
+
+    queriesSent++;
+    const requestPromise = requestFunction(
+      exampleDefinition.api,
+      exampleDefinition.path,
+      requestQueryObject
+    );
+
+    try {
+      const result = await requestPromise;
+      // Unmerge back into individual results
+      mergeList.forEach(queryDefinition => {
+        const propertyName = Array.isArray(
+          queryDefinition.requestQueryObject.query[queryDefinition.batchProperty]
+        )
+          ? queryDefinition.requestQueryObject.query[queryDefinition.batchProperty][0]
+          : queryDefinition.requestQueryObject.query[queryDefinition.batchProperty];
+
+        const singleResult = result[propertyName];
+        queryDefinition.resolve(singleResult);
+      });
+    } catch (e) {
+      // On error fail all requests relying on this merged query (for now)
+      mergeList.forEach(q => q.reject(e));
+    }
+  });
+  return queriesSent;
+}
+
+function handleBatching(
+  organization: Organization,
+  queries: Record<symbol, BatchQueryDefinition>
+) {
+  const mergeMap = queriesToMap(queries);
+
+  if (!mergeMap) {
+    return;
+  }
+
+  let queriesSent = 0;
+  queriesSent += _handleUnmergeableQueries(mergeMap);
+  queriesSent += _handleMergeableQueries(mergeMap);
+
+  const queriesCollected = Object.values(mergeMap).reduce(
+    (acc, mergeList) => acc + mergeList.length,
+    0
+  );
+
+  const queriesSaved = queriesCollected - queriesSent;
+
+  trackAdvancedAnalyticsEvent('performance_views.landingv3.batch_queries', {
+    organization,
+    num_collected: queriesCollected,
+    num_saved: queriesSaved,
+    num_sent: queriesSent,
+  });
+}
+
+export const GenericQueryBatcher = ({children}: {children: React.ReactNode}) => {
+  const queries = useRef<Record<symbol, BatchQueryDefinition>>({});
+
+  const timeoutId = useRef<NodeJS.Timeout | undefined>();
+  const organization = useOrganization();
+
+  const addQuery = (q: BatchQueryDefinition, id: symbol) => {
+    queries.current[id] = q;
+
+    if (timeoutId.current) {
+      clearTimeout(timeoutId.current);
+      timeoutId.current = undefined;
+    }
+    // Put batch function in the next macro task to aggregate all requests in this frame.
+    const tID = setTimeout(() => {
+      handleBatching(organization, queries.current);
+      timeoutId.current = undefined;
+    }, 0);
+    timeoutId.current = tID;
+  };
+
+  // Cleanup timeout after component unmounts.
+  useEffect(() => () => timeoutId.current && clearTimeout(timeoutId.current), []);
+
+  return (
+    <GenericQueryBatcherProvider
+      value={{
+        addQuery,
+      }}
+    >
+      {children}
+    </GenericQueryBatcherProvider>
+  );
+};
+
+type NodeContext = {
+  id: Ref<Symbol>;
+  batchProperty: string;
+};
+
+const BatchNodeContext = createContext<NodeContext | undefined>(undefined);
+
+export type QueryBatching = {
+  batchRequest: (_: Client, path: string, query: QueryObject) => Promise<any>;
+};
+
+// Wraps api request components to collect at most one request per frame / render pass using symbol as a unique id.
+// Transforms these requests into an intermediate promise and adds a query definition that the batch function will use.
+export function QueryBatchNode(props: {
+  batchProperty: string;
+  children(_: any): React.ReactNode;
+}) {
+  const {batchProperty, children} = props;
+  const id = useRef(Symbol());
+
+  let batchContext: QueryBatch;
+  try {
+    batchContext = _useGenericQueryBatcher();
+  } catch (_) {
+    return <Fragment>{children({})}</Fragment>;
+  }
+
+  const api = useApi();
+
+  function batchRequest(
+    _: Client,
+    path: string,
+    requestQueryObject: QueryObject
+  ): Promise<any> {
+    const queryPromise = new Promise((resolve, reject) => {
+      const queryDefinition: BatchQueryDefinition = {
+        resolve,
+        reject,
+        batchProperty,
+        path,
+        requestQueryObject,
+        api,
+      };
+      batchContext?.addQuery(queryDefinition, id.current);
+    });
+    return queryPromise;
+  }
+
+  const queryBatching: QueryBatching = {
+    batchRequest,
+  };
+
+  return (
+    <BatchNodeContext.Provider
+      value={{
+        id,
+        batchProperty,
+      }}
+    >
+      {children({queryBatching})}
+    </BatchNodeContext.Provider>
+  );
+}

+ 4 - 1
static/app/views/performance/landing/index.tsx

@@ -17,6 +17,7 @@ import space from 'app/styles/space';
 import {Organization, Project} from 'app/types';
 import EventView from 'app/utils/discover/eventView';
 import {generateAggregateFields} from 'app/utils/discover/fields';
+import {GenericQueryBatcher} from 'app/utils/performance/contexts/genericQueryBatcher';
 import useTeams from 'app/utils/useTeams';
 
 import {MetricsSwitch} from '../metricsSwitch';
@@ -141,7 +142,9 @@ export function PerformanceLanding(props: Props) {
               selectedTeams={['myteams']}
               selectedProjects={eventView.project.map(String)}
             >
-              <ViewComponent {...props} />
+              <GenericQueryBatcher>
+                <ViewComponent {...props} />
+              </GenericQueryBatcher>
             </TeamKeyTransactionManager.Provider>
           ) : (
             <LoadingIndicator />

+ 3 - 3
static/app/views/performance/landing/widgets/components/dataStateSwitch.tsx

@@ -10,12 +10,12 @@ export function DataStateSwitch(props: {
   dataComponents: JSX.Element[];
   emptyComponent: JSX.Element;
 }): JSX.Element {
-  if (props.isLoading && props.loadingComponent) {
-    return props.loadingComponent;
-  }
   if (props.isErrored) {
     return props.errorComponent;
   }
+  if (props.isLoading && props.loadingComponent) {
+    return props.loadingComponent;
+  }
   if (!props.hasData) {
     return props.emptyComponent;
   }

+ 2 - 1
static/app/views/performance/landing/widgets/components/performanceWidget.tsx

@@ -123,6 +123,7 @@ function _DataDisplay<T extends WidgetDataConstraint>(
             key={index}
             noPadding={Visualization.noPadding}
             bottomPadding={Visualization.bottomPadding}
+            data-test-id="widget-state-has-data"
             onClick={() =>
               trackDataComponentClicks(props.chartSetting, props.organization)
             }
@@ -152,7 +153,7 @@ export const DataDisplay = withRouter(_DataDisplay);
 
 const DefaultErrorComponent = (props: {height: number}) => {
   return (
-    <ErrorPanel height={`${props.height}px`}>
+    <ErrorPanel data-test-id="widget-state-is-errored" height={`${props.height}px`}>
       <IconWarning color="gray300" size="lg" />
     </ErrorPanel>
   );

+ 22 - 16
static/app/views/performance/landing/widgets/widgets/singleFieldAreaWidget.tsx

@@ -9,6 +9,7 @@ import {getInterval} from 'app/components/charts/utils';
 import {t} from 'app/locale';
 import {Organization} from 'app/types';
 import EventView from 'app/utils/discover/eventView';
+import {QueryBatchNode} from 'app/utils/performance/contexts/genericQueryBatcher';
 import withApi from 'app/utils/withApi';
 import _DurationChart from 'app/views/performance/charts/chart';
 
@@ -51,23 +52,28 @@ export function SingleFieldAreaWidget(props: Props) {
     () => ({
       fields: props.fields[0],
       component: provided => (
-        <EventsRequest
-          {...pick(provided, eventsRequestQueryProps)}
-          limit={1}
-          includePrevious
-          includeTransformedData
-          partial
-          currentSeriesNames={[field]}
-          query={provided.eventView.getQueryWithAdditionalConditions()}
-          interval={getInterval(
-            {
-              start: provided.start,
-              end: provided.end,
-              period: provided.period,
-            },
-            'medium'
+        <QueryBatchNode batchProperty="yAxis">
+          {({queryBatching}) => (
+            <EventsRequest
+              {...pick(provided, eventsRequestQueryProps)}
+              limit={1}
+              queryBatching={queryBatching}
+              includePrevious
+              includeTransformedData
+              partial
+              currentSeriesNames={[field]}
+              query={provided.eventView.getQueryWithAdditionalConditions()}
+              interval={getInterval(
+                {
+                  start: provided.start,
+                  end: provided.end,
+                  period: provided.period,
+                },
+                'medium'
+              )}
+            />
           )}
-        />
+        </QueryBatchNode>
       ),
       transform: transformEventsRequestToArea,
     }),

+ 19 - 1
tests/js/spec/views/performance/landing/index.spec.tsx

@@ -155,7 +155,25 @@ describe('Performance > Landing > Index', function () {
 
     expect(wrapper.find('Table').exists()).toBe(true);
 
-    expect(eventStatsMock).toHaveBeenCalledTimes(3); // Currently defaulting to 4 event stat charts on all transactions view + 1 event chart.
+    expect(eventStatsMock).toHaveBeenCalledTimes(1); // Only one request is made since the query batcher is working.
+
+    expect(eventStatsMock).toHaveBeenNthCalledWith(
+      1,
+      expect.anything(),
+      expect.objectContaining({
+        query: expect.objectContaining({
+          environment: [],
+          interval: '1h',
+          partial: '1',
+          project: [],
+          query: '',
+          referrer: 'api.organization-event-stats',
+          statsPeriod: '28d',
+          yAxis: ['user_misery()', 'tpm()', 'failure_rate()'],
+        }),
+      })
+    );
+
     expect(eventsV2Mock).toHaveBeenCalledTimes(2);
 
     const titles = wrapper.find('div[data-test-id="performance-widget-title"]');

+ 276 - 0
tests/js/spec/views/performance/landing/queryBatcher.spec.tsx

@@ -0,0 +1,276 @@
+import {Fragment} from 'react';
+
+import {initializeData as _initializeData} from 'sentry-test/performance/initializePerformanceData';
+import {mountWithTheme, screen} from 'sentry-test/reactTestingLibrary';
+
+import {GenericQueryBatcher} from 'app/utils/performance/contexts/genericQueryBatcher';
+import {PerformanceDisplayProvider} from 'app/utils/performance/contexts/performanceDisplayContext';
+import {OrganizationContext} from 'app/views/organizationContext';
+import WidgetContainer from 'app/views/performance/landing/widgets/components/widgetContainer';
+import {PerformanceWidgetSetting} from 'app/views/performance/landing/widgets/widgetDefinitions';
+import {PROJECT_PERFORMANCE_TYPE} from 'app/views/performance/utils';
+
+const initializeData = () => {
+  const data = _initializeData({
+    query: {statsPeriod: '7d', environment: ['prod'], project: [-42]},
+  });
+
+  data.eventView.additionalConditions.addFilterValues('transaction.op', ['pageload']);
+
+  return data;
+};
+
+const BASIC_QUERY_PARAMS = {
+  interval: '1h',
+  partial: '1',
+  query: 'transaction.op:pageload',
+  statsPeriod: '14d',
+};
+
+const WrappedComponent = ({data, ...rest}) => {
+  return (
+    <PerformanceDisplayProvider value={{performanceType: PROJECT_PERFORMANCE_TYPE.ANY}}>
+      <OrganizationContext.Provider value={data.organization}>
+        <WidgetContainer
+          allowedCharts={[
+            PerformanceWidgetSetting.TPM_AREA,
+            PerformanceWidgetSetting.FAILURE_RATE_AREA,
+            PerformanceWidgetSetting.USER_MISERY_AREA,
+          ]}
+          rowChartSettings={[]}
+          forceDefaultChartSetting
+          {...data}
+          {...rest}
+        />
+      </OrganizationContext.Provider>
+    </PerformanceDisplayProvider>
+  );
+};
+
+describe('Performance > Widgets > Query Batching', function () {
+  let eventStatsMock;
+
+  beforeEach(function () {
+    eventStatsMock = MockApiClient.addMockResponse({
+      method: 'GET',
+      url: `/organizations/org-slug/events-stats/`,
+      body: {
+        'tpm()': {
+          data: [
+            [
+              1636822800,
+              [
+                {
+                  count: 30.0,
+                },
+              ],
+            ],
+            [
+              1636995600,
+              [
+                {
+                  count: 60.1,
+                },
+              ],
+            ],
+          ],
+          order: 1,
+          start: 1636822800,
+          end: 1636995600,
+        },
+        'user_misery()': {
+          data: [
+            [
+              1636822800,
+              [
+                {
+                  count: 0.02,
+                },
+              ],
+            ],
+            [
+              1636995600,
+              [
+                {
+                  count: 0.03,
+                },
+              ],
+            ],
+          ],
+          order: 1,
+          start: 1636822800,
+          end: 1636995600,
+        },
+        'failure_rate()': {
+          data: [
+            [
+              1636822800,
+              [
+                {
+                  count: 0.002,
+                },
+              ],
+            ],
+            [
+              1636995600,
+              [
+                {
+                  count: 0.001,
+                },
+              ],
+            ],
+          ],
+          order: 2,
+          start: 1636822800,
+          end: 1636995600,
+        },
+      },
+    });
+  });
+
+  it('EventsRequest based component fires query without provider', async function () {
+    const data = initializeData();
+
+    mountWithTheme(
+      <WrappedComponent
+        data={data}
+        defaultChartSetting={PerformanceWidgetSetting.TPM_AREA}
+      />
+    );
+
+    expect(await screen.findByTestId('performance-widget-title')).toBeInTheDocument();
+
+    expect(eventStatsMock).toHaveBeenCalledTimes(1);
+    expect(eventStatsMock).toHaveBeenNthCalledWith(
+      1,
+      expect.anything(),
+      expect.objectContaining({
+        query: expect.objectContaining({
+          ...BASIC_QUERY_PARAMS,
+          yAxis: 'tpm()',
+        }),
+      })
+    );
+  });
+
+  it('Multiple EventsRequest based components fire individual queries without provider', async function () {
+    const data = initializeData();
+
+    mountWithTheme(
+      <Fragment>
+        <WrappedComponent
+          data={data}
+          defaultChartSetting={PerformanceWidgetSetting.TPM_AREA}
+        />
+        <WrappedComponent
+          data={data}
+          defaultChartSetting={PerformanceWidgetSetting.FAILURE_RATE_AREA}
+        />
+        <WrappedComponent
+          data={data}
+          defaultChartSetting={PerformanceWidgetSetting.USER_MISERY_AREA}
+        />
+      </Fragment>
+    );
+
+    expect(await screen.findAllByTestId('performance-widget-title')).toHaveLength(3);
+
+    // Three requests are made
+    expect(eventStatsMock).toHaveBeenCalledTimes(3);
+    expect(eventStatsMock).toHaveBeenNthCalledWith(
+      1,
+      expect.anything(),
+      expect.objectContaining({
+        query: expect.objectContaining({
+          ...BASIC_QUERY_PARAMS,
+          yAxis: 'tpm()',
+        }),
+      })
+    );
+  });
+
+  it('Multiple EventsRequest based component merge queries with provider ', async function () {
+    const data = initializeData();
+
+    mountWithTheme(
+      <OrganizationContext.Provider value={data.organization}>
+        <GenericQueryBatcher>
+          <WrappedComponent
+            data={data}
+            defaultChartSetting={PerformanceWidgetSetting.TPM_AREA}
+          />
+          <WrappedComponent
+            data={data}
+            defaultChartSetting={PerformanceWidgetSetting.FAILURE_RATE_AREA}
+          />
+          <WrappedComponent
+            data={data}
+            defaultChartSetting={PerformanceWidgetSetting.USER_MISERY_AREA}
+          />
+        </GenericQueryBatcher>
+      </OrganizationContext.Provider>
+    );
+
+    expect(await screen.findAllByTestId('performance-widget-title')).toHaveLength(3);
+
+    expect(eventStatsMock).toHaveBeenNthCalledWith(
+      1,
+      expect.anything(),
+      expect.objectContaining({
+        query: expect.objectContaining({
+          ...BASIC_QUERY_PARAMS,
+          yAxis: ['tpm()', 'failure_rate()', 'user_misery()'],
+        }),
+      })
+    );
+    expect(eventStatsMock).toHaveBeenCalledTimes(1);
+
+    expect(await screen.findAllByTestId('widget-state-has-data')).toHaveLength(3);
+  });
+
+  it('Errors work correctly', async function () {
+    eventStatsMock = MockApiClient.addMockResponse({
+      method: 'GET',
+      url: `/organizations/org-slug/events-stats/`,
+      statusCode: 404,
+      body: {},
+    });
+
+    const data = initializeData();
+
+    mountWithTheme(
+      <OrganizationContext.Provider value={data.organization}>
+        <GenericQueryBatcher>
+          <WrappedComponent
+            data={data}
+            defaultChartSetting={PerformanceWidgetSetting.TPM_AREA}
+          />
+          <WrappedComponent
+            data={data}
+            defaultChartSetting={PerformanceWidgetSetting.FAILURE_RATE_AREA}
+          />
+          <WrappedComponent
+            data={data}
+            defaultChartSetting={PerformanceWidgetSetting.USER_MISERY_AREA}
+          />
+        </GenericQueryBatcher>
+      </OrganizationContext.Provider>
+    );
+
+    expect(await screen.findAllByTestId('performance-widget-title')).toHaveLength(3);
+
+    expect(eventStatsMock).toHaveBeenNthCalledWith(
+      1,
+      expect.anything(),
+      expect.objectContaining({
+        query: expect.objectContaining({
+          ...BASIC_QUERY_PARAMS,
+          yAxis: ['tpm()', 'failure_rate()', 'user_misery()'],
+        }),
+      })
+    );
+    expect(eventStatsMock).toHaveBeenCalledTimes(1);
+
+    expect(await screen.findAllByTestId('widget-state-is-errored')).toHaveLength(3);
+  });
+});