Browse Source

fix(issues): revert groupstore stats changes (#60856)

Revert these last 2 PR's until I can figure out exactly what is
happening. The current implementation mostly works, but there still see
to be subtle edge cases where we fail to collect show stat data
Jonas 1 year ago
parent
commit
6c1d233c64

+ 2 - 3
static/app/components/eventOrGroupExtraDetails.tsx

@@ -18,7 +18,6 @@ import type {Group, Organization} from 'sentry/types';
 import {Event} from 'sentry/types/event';
 import {projectCanLinkToReplay} from 'sentry/utils/replays/projectSupportsReplay';
 import withOrganization from 'sentry/utils/withOrganization';
-import {useGroupStats} from 'sentry/views/issueList/groupStatsProvider';
 
 type Props = {
   data: Event | Group;
@@ -44,13 +43,13 @@ function EventOrGroupExtraDetails({
     annotations,
     shortId,
     project,
+    lifetime,
+    isUnhandled,
     inbox,
     status,
     substatus,
   } = data as Group;
 
-  const {lifetime, isUnhandled} = useGroupStats(data as Group);
-
   const issuesPath = `/organizations/${organization.slug}/issues/`;
 
   const showReplayCount =

+ 17 - 21
static/app/components/stream/group.tsx

@@ -43,7 +43,6 @@ import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import withOrganization from 'sentry/utils/withOrganization';
 import {TimePeriodType} from 'sentry/views/alerts/rules/metric/details/constants';
-import {useGroupStats} from 'sentry/views/issueList/groupStatsProvider';
 import {
   DISCOVER_EXCLUSION_FIELDS,
   getTabs,
@@ -247,8 +246,7 @@ function BaseGroupRow({
   };
 
   const renderReprocessingColumns = () => {
-    const {count} = stats;
-    const {statusDetails} = group as GroupReprocessing;
+    const {statusDetails, count} = group as GroupReprocessing;
     const {info, pendingEvents} = statusDetails;
 
     if (!info) {
@@ -286,20 +284,18 @@ function BaseGroupRow({
     );
   };
 
-  const stats = useGroupStats(group);
-
   // Use data.filtered to decide on which value to use
   // In case of the query has filters but we avoid showing both sets of filtered/unfiltered stats
   // we use useFilteredStats param passed to Group for deciding
-  const primaryCount = stats.filtered ? stats.filtered.count : stats.count;
-  const secondaryCount = stats.filtered ? stats.count : undefined;
-  const primaryUserCount = stats.filtered ? stats.filtered.userCount : stats.userCount;
-  const secondaryUserCount = stats.filtered ? stats.userCount : undefined;
+  const primaryCount = group.filtered ? group.filtered.count : group.count;
+  const secondaryCount = group.filtered ? group.count : undefined;
+  const primaryUserCount = group.filtered ? group.filtered.userCount : group.userCount;
+  const secondaryUserCount = group.filtered ? group.userCount : undefined;
   // preview stats
-  const lastTriggeredDate = stats.lastTriggered;
+  const lastTriggeredDate = group.lastTriggered;
 
   const showSecondaryPoints = Boolean(
-    withChart && group && stats.filtered && statsPeriod && useFilteredStats
+    withChart && group && group.filtered && statsPeriod && useFilteredStats
   );
 
   const groupCategoryCountTitles: Record<IssueCategory, string> = {
@@ -319,24 +315,24 @@ function BaseGroupRow({
         title={
           <CountTooltipContent>
             <h4>{groupCategoryCountTitles[group.issueCategory]}</h4>
-            {stats.filtered && (
+            {group.filtered && (
               <Fragment>
                 <div>{queryFilterDescription ?? t('Matching filters')}</div>
                 <Link to={getDiscoverUrl(true)}>
-                  <Count value={stats.filtered?.count} />
+                  <Count value={group.filtered?.count} />
                 </Link>
               </Fragment>
             )}
             <Fragment>
               <div>{t('Total in %s', summary)}</div>
               <Link to={getDiscoverUrl()}>
-                <Count value={stats.count} />
+                <Count value={group.count} />
               </Link>
             </Fragment>
-            {stats.lifetime && (
+            {group.lifetime && (
               <Fragment>
                 <div>{t('Since issue began')}</div>
-                <Count value={stats.lifetime.count} />
+                <Count value={group.lifetime.count} />
               </Fragment>
             )}
           </CountTooltipContent>
@@ -359,24 +355,24 @@ function BaseGroupRow({
       title={
         <CountTooltipContent>
           <h4>{t('Affected Users')}</h4>
-          {stats.filtered && (
+          {group.filtered && (
             <Fragment>
               <div>{queryFilterDescription ?? t('Matching filters')}</div>
               <Link to={getDiscoverUrl(true)}>
-                <Count value={stats.filtered?.userCount} />
+                <Count value={group.filtered?.userCount} />
               </Link>
             </Fragment>
           )}
           <Fragment>
             <div>{t('Total in %s', summary)}</div>
             <Link to={getDiscoverUrl()}>
-              <Count value={stats.userCount} />
+              <Count value={group.userCount} />
             </Link>
           </Fragment>
-          {stats.lifetime && (
+          {group.lifetime && (
             <Fragment>
               <div>{t('Since issue began')}</div>
-              <Count value={stats.lifetime.userCount} />
+              <Count value={group.lifetime.userCount} />
             </Fragment>
           )}
         </CountTooltipContent>

+ 4 - 6
static/app/components/stream/groupChart.tsx

@@ -8,7 +8,6 @@ import {Group, TimeseriesValue} from 'sentry/types';
 import {Series} from 'sentry/types/echarts';
 import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
 import theme from 'sentry/utils/theme';
-import {useGroupStats} from 'sentry/views/issueList/groupStatsProvider';
 
 function asChartPoint(point: [number, number]): {name: number | string; value: number} {
   return {
@@ -34,15 +33,14 @@ function GroupChart({
   height = 24,
   showMarkLine = false,
 }: Props) {
-  const groupStats = useGroupStats(data);
   const stats: ReadonlyArray<TimeseriesValue> = statsPeriod
-    ? groupStats.filtered
-      ? groupStats.filtered.stats?.[statsPeriod]
-      : groupStats.stats?.[statsPeriod]
+    ? data.filtered
+      ? data.filtered.stats?.[statsPeriod]
+      : data.stats?.[statsPeriod]
     : EMPTY_STATS;
 
   const secondaryStats: TimeseriesValue[] | null =
-    statsPeriod && groupStats.filtered ? groupStats.stats[statsPeriod] : null;
+    statsPeriod && data.filtered ? data.stats[statsPeriod] : null;
 
   const [series, colors, emphasisColors]: [
     Series[],

+ 43 - 1
static/app/stores/groupStore.spec.tsx

@@ -1,7 +1,7 @@
 import {Project} from 'sentry-fixture/project';
 
 import GroupStore from 'sentry/stores/groupStore';
-import {Group, GroupActivityType} from 'sentry/types';
+import {Group, GroupActivityType, GroupStats, TimeseriesValue} from 'sentry/types';
 
 const MOCK_PROJECT = TestStubs.Project();
 
@@ -105,6 +105,48 @@ describe('GroupStore', function () {
     });
   });
 
+  describe('onPopulateStats()', function () {
+    const stats: Record<string, TimeseriesValue[]> = {auto: [[1611576000, 10]]};
+
+    beforeEach(function () {
+      jest.spyOn(GroupStore, 'trigger');
+      GroupStore.items = [g('1'), g('2'), g('3')];
+    });
+    afterEach(() => {
+      jest.restoreAllMocks();
+    });
+
+    it('should merge stats into existing groups', function () {
+      GroupStore.onPopulateStats(
+        ['1', '2', '3'],
+        [
+          {id: '1', stats} as GroupStats,
+          {id: '2', stats} as GroupStats,
+          {id: '3', stats} as GroupStats,
+        ]
+      );
+
+      const group = GroupStore.getAllItems()[0] as Group;
+
+      expect(group.stats).toEqual(stats);
+      expect(GroupStore.trigger).toHaveBeenCalledWith(new Set(['1', '2', '3']));
+    });
+
+    it('should not change current item ids', function () {
+      GroupStore.onPopulateStats(
+        ['2', '3'],
+        [{id: '2', stats} as GroupStats, {id: '3', stats} as GroupStats]
+      );
+
+      const group1 = GroupStore.getAllItems()[0] as Group;
+      const group2 = GroupStore.getAllItems()[1] as Group;
+
+      expect(GroupStore.trigger).toHaveBeenCalledWith(new Set(['2', '3']));
+      expect(group1.stats).not.toEqual(stats);
+      expect(group2.stats).toEqual(stats);
+    });
+  });
+
   describe('getAllItems()', function () {
     it('Merges pending changes into items', function () {
       GroupStore.items = [];

+ 21 - 1
static/app/stores/groupStore.tsx

@@ -3,7 +3,7 @@ import {createStore} from 'reflux';
 import {Indicator} from 'sentry/actionCreators/indicator';
 import {t} from 'sentry/locale';
 import IndicatorStore from 'sentry/stores/indicatorStore';
-import {Activity, BaseGroup, Group} from 'sentry/types';
+import {Activity, BaseGroup, Group, GroupStats} from 'sentry/types';
 import RequestError from 'sentry/utils/requestError/requestError';
 import toArray from 'sentry/utils/toArray';
 
@@ -70,6 +70,8 @@ interface GroupStoreDefinition extends CommonStoreDefinition<Item[]>, InternalDe
   onMergeError: (changeId: string, itemIds: ItemIds, response: any) => void;
   onMergeSuccess: (changeId: string, itemIds: ItemIds, response: any) => void;
 
+  onPopulateStats: (itemIds: ItemIds, response: GroupStats[]) => void;
+
   onUpdate: (changeId: string, itemIds: ItemIds, data: any) => void;
   onUpdateError: (changeId: string, itemIds: ItemIds, silent: boolean) => void;
   onUpdateSuccess: (changeId: string, itemIds: ItemIds, response: Partial<Group>) => void;
@@ -457,6 +459,24 @@ const storeConfig: GroupStoreDefinition = {
     this.pendingChanges.delete(changeId);
     this.updateItems(ids);
   },
+
+  onPopulateStats(itemIds, response) {
+    // Organize stats by id
+    const groupStatsMap = response.reduce<Record<string, GroupStats>>(
+      (map, stats) => ({...map, [stats.id]: stats}),
+      {}
+    );
+
+    this.items.forEach((item, idx) => {
+      if (itemIds?.includes(item.id)) {
+        this.items[idx] = {
+          ...item,
+          ...groupStatsMap[item.id],
+        };
+      }
+    });
+    this.updateItems(itemIds);
+  },
 };
 
 const GroupStore = createStore(storeConfig);

+ 1 - 2
static/app/utils/parseApiError.tsx

@@ -1,7 +1,6 @@
 import {ResponseMeta} from 'sentry/api';
-import RequestError from 'sentry/utils/requestError/requestError';
 
-export default function parseApiError(resp: ResponseMeta | RequestError): string {
+export default function parseApiError(resp: ResponseMeta): string {
   const {detail} = (resp && resp.responseJSON) || ({} as object);
 
   // return immediately if string

+ 0 - 142
static/app/views/issueList/groupStatsProvider.tsx

@@ -1,142 +0,0 @@
-import {createContext, useContext, useEffect, useState} from 'react';
-import * as Sentry from '@sentry/react';
-import {dropUndefinedKeys} from '@sentry/utils';
-import * as reactQuery from '@tanstack/react-query';
-
-import {ApiResult} from 'sentry/api';
-import type {Group, GroupStats, Organization, PageFilters} from 'sentry/types';
-import {getUtcDateString} from 'sentry/utils/dates';
-import {UseQueryResult} from 'sentry/utils/queryClient';
-import RequestError from 'sentry/utils/requestError/requestError';
-import useApi from 'sentry/utils/useApi';
-
-function getEndpointParams(
-  p: Pick<GroupStatsProviderProps, 'selection' | 'period' | 'query' | 'groupIds'>
-): StatEndpointParams {
-  const params: StatEndpointParams = {
-    project: p.selection.projects,
-    environment: p.selection.environments,
-    groupStatsPeriod: p.period,
-    query: p.query,
-    groups: p.groupIds,
-    ...p.selection.datetime,
-  };
-
-  if (p.selection.datetime.period) {
-    delete params.period;
-    params.statsPeriod = p.selection.datetime.period;
-  }
-  if (params.end) {
-    params.end = getUtcDateString(params.end);
-  }
-  if (params.start) {
-    params.start = getUtcDateString(params.start);
-  }
-
-  return dropUndefinedKeys(params);
-}
-
-const GroupStatsContext = createContext<UseQueryResult<
-  Record<string, GroupStats>
-> | null>(null);
-
-export function useGroupStats(group: Group): GroupStats {
-  const ctx = useContext(GroupStatsContext);
-
-  if (!ctx) {
-    return group;
-  }
-
-  return ctx.data?.[group.id] ?? group;
-}
-
-interface StatEndpointParams extends Partial<PageFilters['datetime']> {
-  environment: string[];
-  groups: Group['id'][];
-  project: number[];
-  cursor?: string;
-  expand?: string | string[];
-  groupStatsPeriod?: string | null;
-  page?: number | string;
-  query?: string | undefined;
-  sort?: string;
-  statsPeriod?: string | null;
-}
-
-export type GroupStatsQuery = UseQueryResult<Record<string, GroupStats>, RequestError>;
-
-export interface GroupStatsProviderProps {
-  children: React.ReactNode;
-  groupIds: Group['id'][];
-  organization: Organization;
-  period: string;
-
-  selection: PageFilters;
-  onStatsQuery?: (query: GroupStatsQuery) => void;
-  query?: string;
-}
-
-export function GroupStatsProvider(props: GroupStatsProviderProps) {
-  const api = useApi();
-  const [groupStats, setGroupStats] = useState<Record<string, GroupStats>>({});
-
-  const queryFn = (): Promise<Record<string, GroupStats>> => {
-    const promise = api
-      .requestPromise<true>(`/organizations/${props.organization.slug}/issues-stats/`, {
-        method: 'GET',
-        query: getEndpointParams({
-          selection: props.selection,
-          period: props.period,
-          query: props.query,
-          groupIds: props.groupIds,
-        }),
-        includeAllArgs: true,
-      })
-      .then((resp: ApiResult<GroupStats[]>): Record<string, GroupStats> => {
-        const map: Record<string, GroupStats> = {...groupStats};
-        if (!resp || !Array.isArray(resp[0])) {
-          return map;
-        }
-        for (const stat of resp[0]) {
-          map[stat.id] = stat;
-        }
-        setGroupStats(map);
-        return map;
-      })
-      .catch(e => {
-        Sentry.captureException(e);
-        return {};
-      });
-
-    return promise;
-  };
-
-  const statsQuery = reactQuery.useQuery<Record<string, GroupStats>, RequestError>(
-    [
-      `/organizations/${props.organization.slug}/issues-stats/`,
-      props.selection,
-      props.period,
-      props.query,
-      props.groupIds,
-    ],
-    queryFn,
-    {
-      enabled: props.groupIds.length > 0,
-      staleTime: Infinity,
-    }
-  );
-
-  const onStatsQuery = props.onStatsQuery;
-  useEffect(() => {
-    onStatsQuery?.(statsQuery);
-    // We only want to fire the observer when the status changes
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [statsQuery.status, onStatsQuery]);
-
-  return (
-    // @ts-expect-error we are overriding data with the stored version
-    <GroupStatsContext.Provider value={{...statsQuery, data: groupStats}}>
-      {props.children}
-    </GroupStatsContext.Provider>
-  );
-}

+ 35 - 225
static/app/views/issueList/overview.spec.tsx

@@ -1,10 +1,7 @@
 import {browserHistory} from 'react-router';
 import merge from 'lodash/merge';
-import {Group} from 'sentry-fixture/group';
 import {GroupStats} from 'sentry-fixture/groupStats';
-import {Member} from 'sentry-fixture/member';
 import {Organization} from 'sentry-fixture/organization';
-import {Project} from 'sentry-fixture/project';
 import {Search} from 'sentry-fixture/search';
 import {Tags} from 'sentry-fixture/tags';
 
@@ -19,7 +16,7 @@ import {
 } from 'sentry-test/reactTestingLibrary';
 import {textWithMarkupMatcher} from 'sentry-test/utils';
 
-import GroupStore from 'sentry/stores/groupStore';
+import StreamGroup from 'sentry/components/stream/group';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import TagStore from 'sentry/stores/tagStore';
 import {SavedSearchVisibility} from 'sentry/types';
@@ -29,16 +26,17 @@ import IssueListWithStores, {IssueListOverview} from 'sentry/views/issueList/ove
 
 // Mock <IssueListActions>
 jest.mock('sentry/views/issueList/actions', () => jest.fn(() => null));
+jest.mock('sentry/components/stream/group', () => jest.fn(() => null));
 
 const DEFAULT_LINKS_HEADER =
   '<http://127.0.0.1:8000/api/0/organizations/org-slug/issues/?cursor=1443575731:0:1>; rel="previous"; results="false"; cursor="1443575731:0:1", ' +
   '<http://127.0.0.1:8000/api/0/organizations/org-slug/issues/?cursor=1443575000:0:0>; rel="next"; results="true"; cursor="1443575000:0:0"';
 
-const project = Project({
+const project = TestStubs.Project({
   id: '3559',
   name: 'Foo Project',
   slug: 'project-slug',
-  firstEvent: new Date().toISOString(),
+  firstEvent: true,
 });
 
 const {organization, router, routerContext} = initializeOrg({
@@ -64,7 +62,7 @@ describe('IssueList', function () {
   let props;
 
   const tags = Tags();
-  const group = Group({project});
+  const group = TestStubs.Group({project});
   const groupStats = GroupStats();
   const savedSearch = Search({
     id: '789',
@@ -133,7 +131,7 @@ describe('IssueList', function () {
     fetchMembersRequest = MockApiClient.addMockResponse({
       url: '/organizations/org-slug/users/',
       method: 'GET',
-      body: [Member({projects: [project.slug]})],
+      body: [TestStubs.Member({projects: [project.slug]})],
     });
     MockApiClient.addMockResponse({
       url: '/organizations/org-slug/sent-first-event/',
@@ -179,6 +177,8 @@ describe('IssueList', function () {
     let issuesRequest: jest.Mock;
 
     beforeEach(function () {
+      jest.mocked(StreamGroup).mockClear();
+
       recentSearchesRequest = MockApiClient.addMockResponse({
         url: '/organizations/org-slug/recent-searches/',
         method: 'GET',
@@ -437,11 +437,9 @@ describe('IssueList', function () {
     });
 
     it('caches the search results', async function () {
-      act(() => ProjectsStore.loadInitialData([project]));
-
       issuesRequest = MockApiClient.addMockResponse({
         url: '/organizations/org-slug/issues/',
-        body: [...new Array(25)].map((_, i) => Group({id: `${i}`})),
+        body: [...new Array(25)].map((_, i) => ({id: i})),
         headers: {
           Link: DEFAULT_LINKS_HEADER,
           'X-Hits': '500',
@@ -1166,23 +1164,23 @@ describe('IssueList', function () {
 
     it('displays when no projects selected and all projects user is member of, async does not have first event', async function () {
       const projects = [
-        Project({
+        TestStubs.Project({
           id: '1',
           slug: 'foo',
           isMember: true,
-          firstEvent: undefined,
+          firstEvent: false,
         }),
-        Project({
+        TestStubs.Project({
           id: '2',
           slug: 'bar',
           isMember: true,
-          firstEvent: undefined,
+          firstEvent: false,
         }),
-        Project({
+        TestStubs.Project({
           id: '3',
           slug: 'baz',
           isMember: true,
-          firstEvent: undefined,
+          firstEvent: false,
         }),
       ];
       MockApiClient.addMockResponse({
@@ -1212,23 +1210,23 @@ describe('IssueList', function () {
 
     it('does not display when no projects selected and any projects have a first event', async function () {
       const projects = [
-        Project({
+        TestStubs.Project({
           id: '1',
           slug: 'foo',
           isMember: true,
-          firstEvent: '',
+          firstEvent: false,
         }),
-        Project({
+        TestStubs.Project({
           id: '2',
           slug: 'bar',
           isMember: true,
-          firstEvent: new Date().toISOString(),
+          firstEvent: true,
         }),
-        Project({
+        TestStubs.Project({
           id: '3',
           slug: 'baz',
           isMember: true,
-          firstEvent: new Date().toISOString(),
+          firstEvent: false,
         }),
       ];
       MockApiClient.addMockResponse({
@@ -1253,23 +1251,23 @@ describe('IssueList', function () {
 
     it('displays when all selected projects do not have first event', async function () {
       const projects = [
-        Project({
+        TestStubs.Project({
           id: '1',
           slug: 'foo',
           isMember: true,
-          firstEvent: undefined,
+          firstEvent: false,
         }),
-        Project({
+        TestStubs.Project({
           id: '2',
           slug: 'bar',
           isMember: true,
-          firstEvent: undefined,
+          firstEvent: false,
         }),
-        Project({
+        TestStubs.Project({
           id: '3',
           slug: 'baz',
           isMember: true,
-          firstEvent: undefined,
+          firstEvent: false,
         }),
       ];
       MockApiClient.addMockResponse({
@@ -1304,23 +1302,23 @@ describe('IssueList', function () {
 
     it('does not display when any selected projects have first event', async function () {
       const projects = [
-        Project({
+        TestStubs.Project({
           id: '1',
           slug: 'foo',
           isMember: true,
-          firstEvent: undefined,
+          firstEvent: false,
         }),
-        Project({
+        TestStubs.Project({
           id: '2',
           slug: 'bar',
           isMember: true,
-          firstEvent: new Date().toISOString(),
+          firstEvent: true,
         }),
-        Project({
+        TestStubs.Project({
           id: '3',
           slug: 'baz',
           isMember: true,
-          firstEvent: new Date().toISOString(),
+          firstEvent: true,
         }),
       ];
       MockApiClient.addMockResponse({
@@ -1351,11 +1349,9 @@ describe('IssueList', function () {
   });
 
   it('displays a count that represents the current page', function () {
-    act(() => ProjectsStore.loadInitialData([project]));
-
     MockApiClient.addMockResponse({
       url: '/organizations/org-slug/issues/',
-      body: [...new Array(25)].map((_, i) => Group({id: `${i}`})),
+      body: [...new Array(25)].map((_, i) => ({id: i})),
       headers: {
         Link: DEFAULT_LINKS_HEADER,
         'X-Hits': '500',
@@ -1444,7 +1440,7 @@ describe('IssueList', function () {
       });
 
       it('for multiple projects', function () {
-        const projectBar = Project({
+        const projectBar = TestStubs.Project({
           id: '3560',
           name: 'Bar Project',
           slug: 'project-slug-bar',
@@ -1489,189 +1485,3 @@ describe('IssueList', function () {
     });
   });
 });
-
-describe('stats', () => {
-  const tags = Tags();
-  const groupStats = GroupStats();
-
-  const savedSearch = Search({
-    id: '789',
-    query: 'is:unresolved TypeError',
-    sort: 'date',
-    name: 'Unresolved TypeErrors',
-  });
-
-  beforeEach(() => {
-    GroupStore.loadInitialData([]);
-    MockApiClient.clearMockResponses();
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/searches/',
-      body: [savedSearch],
-    });
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/recent-searches/',
-      body: [],
-    });
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/recent-searches/',
-      method: 'POST',
-      body: [],
-    });
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/issues-count/',
-      method: 'GET',
-      body: [{}],
-    });
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/processingissues/',
-      method: 'GET',
-      body: [
-        {
-          project: 'test-project',
-          numIssues: 1,
-          hasIssues: true,
-          lastSeen: '2019-01-16T15:39:11.081Z',
-        },
-      ],
-    });
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/tags/',
-      method: 'GET',
-      body: tags,
-    });
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/users/',
-      method: 'GET',
-      body: [Member({projects: [project.slug]})],
-    });
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/sent-first-event/',
-      body: {sentFirstEvent: true},
-    });
-    MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/projects/',
-      body: [project],
-    });
-  });
-
-  it('fetches stats for route', async () => {
-    const customRouterProps = {
-      params: router.params,
-      location: router.location,
-    };
-
-    const groups: any[] = [];
-    for (let i = 0; i < 25; i++) {
-      groups.push(Group({project, id: `${i}`}));
-    }
-
-    const issuesRequest = MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/issues/',
-      body: groups,
-      headers: {
-        Link: DEFAULT_LINKS_HEADER,
-      },
-    });
-
-    const statsRequest = MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/issues-stats/',
-      body: [groupStats],
-    });
-
-    render(<IssueListWithStores {...customRouterProps} />, {
-      context: routerContext,
-    });
-
-    await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
-
-    expect(issuesRequest).toHaveBeenCalled();
-    expect(statsRequest.mock.calls[0][1].query).toEqual(
-      expect.objectContaining({
-        groups: groups.map(g => g.id),
-      })
-    );
-
-    expect(await screen.findAllByTestId('group')).toHaveLength(25);
-    expect(screen.queryAllByTestId('loading-placeholder')).toHaveLength(0);
-  });
-
-  it('refetches stats for another route', async () => {
-    const customRouterProps = {
-      params: router.params,
-      location: router.location,
-    };
-
-    const groups: any[] = [];
-    const statsForGroups: any[] = [];
-
-    for (let i = 0; i < 25; i++) {
-      groups.push(Group({project, id: `${i}`}));
-      statsForGroups.push(GroupStats({id: `${i}`}));
-    }
-
-    const forReviewGroups: any[] = [];
-    const forReviewGroupStats: any[] = [];
-    for (let i = 0; i < 25; i++) {
-      forReviewGroups.push(Group({project, id: `${groups.length + i}`}));
-      forReviewGroupStats.push(GroupStats({id: `${groups.length + i}`}));
-    }
-
-    const issuesRequest = MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/issues/',
-      body: groups,
-      headers: {
-        Link: DEFAULT_LINKS_HEADER,
-      },
-    });
-
-    const statsRequest = MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/issues-stats/',
-      body: [statsForGroups],
-    });
-
-    const {rerender} = render(<IssueListWithStores {...customRouterProps} />, {
-      context: routerContext,
-    });
-
-    await waitForElementToBeRemoved(() => screen.getByTestId('loading-indicator'));
-
-    expect(issuesRequest).toHaveBeenCalled();
-    expect(statsRequest).toHaveBeenCalled();
-
-    expect(await screen.findAllByTestId('group')).toHaveLength(25);
-    expect(screen.queryAllByTestId('loading-placeholder')).toHaveLength(0);
-
-    const newRouteProps = {
-      ...customRouterProps,
-      location: {
-        ...customRouterProps.location,
-        query: {
-          query:
-            'is:unresolved is:for_review assigned_or_suggested:[me, my_teams, none] ',
-        },
-        hash: '',
-      },
-    };
-
-    const forReviewGroupsRequest = MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/issues/',
-      body: forReviewGroups,
-      headers: {
-        Link: DEFAULT_LINKS_HEADER,
-      },
-    });
-
-    const forReviewStatsRequest = MockApiClient.addMockResponse({
-      url: '/organizations/org-slug/issues-stats/',
-      body: [forReviewGroupStats],
-    });
-
-    rerender(<IssueListWithStores {...newRouteProps} />);
-
-    expect(forReviewGroupsRequest).toHaveBeenCalled();
-    expect(forReviewStatsRequest).toHaveBeenCalled();
-
-    expect(await screen.findAllByTestId('group')).toHaveLength(25);
-    expect(screen.queryAllByTestId('loading-placeholder')).toHaveLength(0);
-  });
-});

+ 69 - 57
static/app/views/issueList/overview.tsx

@@ -57,10 +57,6 @@ import withIssueTags from 'sentry/utils/withIssueTags';
 import withOrganization from 'sentry/utils/withOrganization';
 import withPageFilters from 'sentry/utils/withPageFilters';
 import withSavedSearches from 'sentry/utils/withSavedSearches';
-import {
-  GroupStatsProvider,
-  GroupStatsQuery,
-} from 'sentry/views/issueList/groupStatsProvider';
 import SavedIssueSearches from 'sentry/views/issueList/savedIssueSearches';
 
 import IssueListActions from './actions';
@@ -124,7 +120,6 @@ type State = {
   queryMaxCount: number;
   realtimeActive: boolean;
   selectAllActive: boolean;
-  statsForGroupIds: string[];
   undo: boolean;
   // Will be set to true if there is valid session data from issue-stats api call
   query?: string;
@@ -145,6 +140,11 @@ type CountsEndpointParams = Omit<EndpointParams, 'cursor' | 'page' | 'query'> &
   query: string[];
 };
 
+type StatEndpointParams = Omit<EndpointParams, 'cursor' | 'page'> & {
+  groups: string[];
+  expand?: string | string[];
+};
+
 class IssueListOverview extends Component<Props, State> {
   state: State = this.getInitialState();
 
@@ -157,7 +157,6 @@ class IssueListOverview extends Component<Props, State> {
 
     return {
       groupIds: [],
-      statsForGroupIds: [],
       actionTaken: false,
       actionTakenGroupData: [],
       undo: false,
@@ -303,6 +302,7 @@ class IssueListOverview extends Component<Props, State> {
 
   private _poller: any;
   private _lastRequest: any;
+  private _lastStatsRequest: any;
   private _lastFetchCountsRequest: any;
 
   getQueryFromSavedSearchOrLocation({
@@ -458,6 +458,48 @@ class IssueListOverview extends Component<Props, State> {
     loadOrganizationTags(api, organization.slug, selection);
   }
 
+  fetchStats = (groups: string[]) => {
+    // If we have no groups to fetch, just skip stats
+    if (!groups.length) {
+      return;
+    }
+    const requestParams: StatEndpointParams = {
+      ...this.getEndpointParams(),
+      groups,
+    };
+    // If no stats period values are set, use default
+    if (!requestParams.statsPeriod && !requestParams.start) {
+      requestParams.statsPeriod = DEFAULT_STATS_PERIOD;
+    }
+
+    this._lastStatsRequest = this.props.api.request(this.groupStatsEndpoint, {
+      method: 'GET',
+      data: qs.stringify(requestParams),
+      success: data => {
+        if (!data) {
+          return;
+        }
+        GroupStore.onPopulateStats(groups, data);
+        this.trackTabViewed(groups, data);
+      },
+      error: err => {
+        this.setState({
+          error: parseApiError(err),
+        });
+      },
+      complete: () => {
+        this._lastStatsRequest = null;
+
+        // End navigation transaction to prevent additional page requests from impacting page metrics.
+        // Other transactions include stacktrace preview request
+        const currentTransaction = Sentry.getCurrentHub().getScope()?.getTransaction();
+        if (currentTransaction?.op === 'navigation') {
+          currentTransaction.finish();
+        }
+      },
+    });
+  };
+
   fetchCounts = (currentQueryCount: number, fetchAllCounts: boolean) => {
     const {organization} = this.props;
     const {queryCounts: _queryCounts} = this.state;
@@ -558,7 +600,6 @@ class IssueListOverview extends Component<Props, State> {
     this.setState({
       itemsRemoved: 0,
       error: null,
-      statsForGroupIds: [],
     });
 
     // Used for Issue Stream Performance project, enabled means we are doing saved search look up in the backend
@@ -607,6 +648,9 @@ class IssueListOverview extends Component<Props, State> {
     if (this._lastRequest) {
       this._lastRequest.cancel();
     }
+    if (this._lastStatsRequest) {
+      this._lastStatsRequest.cancel();
+    }
     if (this._lastFetchCountsRequest) {
       this._lastFetchCountsRequest.cancel();
     }
@@ -649,6 +693,8 @@ class IssueListOverview extends Component<Props, State> {
         }
         GroupStore.add(data);
 
+        this.fetchStats(data.map((group: BaseGroup) => group.id));
+
         const hits = resp.getResponseHeader('X-Hits');
         const queryCount =
           typeof hits !== 'undefined' && hits ? parseInt(hits, 10) || 0 : 0;
@@ -662,7 +708,6 @@ class IssueListOverview extends Component<Props, State> {
         this.setState({
           error: null,
           issuesLoading: false,
-          statsForGroupIds: data.map(group => group.id),
           queryCount,
           queryMaxCount,
           pageLinks: pageLinks !== null ? pageLinks : '',
@@ -688,7 +733,6 @@ class IssueListOverview extends Component<Props, State> {
         this.setState({
           error: parseApiError(err),
           issuesLoading: false,
-          statsForGroupIds: [],
         });
       },
       complete: () => {
@@ -1011,6 +1055,9 @@ class IssueListOverview extends Component<Props, State> {
     if (this._lastRequest) {
       this._lastRequest.cancel();
     }
+    if (this._lastStatsRequest) {
+      this._lastStatsRequest.cancel();
+    }
     if (this._lastFetchCountsRequest) {
       this._lastFetchCountsRequest.cancel();
     }
@@ -1168,32 +1215,6 @@ class IssueListOverview extends Component<Props, State> {
     };
   };
 
-  onStatsQuery = (query: GroupStatsQuery) => {
-    switch (query.status) {
-      case 'loading':
-        break;
-      case 'success':
-        const data = Object.values(query.data ?? {});
-        if (data && data[0]) {
-          // The type being cast was wrong in the previous implementations as well
-          // because inference was broken. Ignore it for now.
-          this.trackTabViewed(this.state.groupIds, data as Group[]);
-        }
-        const currentTransaction = Sentry.getCurrentHub().getScope()?.getTransaction();
-        if (currentTransaction?.op === 'navigation') {
-          currentTransaction.finish();
-        }
-        break;
-      case 'error':
-        this.setState({
-          // Missing our custom getResponseHeader function, but parseApiError does not require it
-          error: parseApiError(query?.error),
-        });
-        break;
-      default:
-    }
-  };
-
   render() {
     if (
       this.props.savedSearchLoading &&
@@ -1268,31 +1289,22 @@ class IssueListOverview extends Component<Props, State> {
                   showProject
                 />
                 <VisuallyCompleteWithData
-                  id="IssueList-Body"
                   hasData={this.state.groupIds.length > 0}
+                  id="IssueList-Body"
                   isLoading={this.state.issuesLoading}
                 >
-                  <GroupStatsProvider
-                    groupIds={this.state.statsForGroupIds}
-                    selection={this.props.selection}
-                    organization={this.props.organization}
-                    period={this.getGroupStatsPeriod()}
-                    query={this.getQuery()}
-                    onStatsQuery={this.onStatsQuery}
-                  >
-                    <GroupListBody
-                      memberList={this.state.memberList}
-                      groupStatsPeriod={this.getGroupStatsPeriod()}
-                      groupIds={groupIds}
-                      displayReprocessingLayout={displayReprocessingActions}
-                      sort={this.getSort()}
-                      selectedProjectIds={selection.projects}
-                      loading={issuesLoading}
-                      error={error}
-                      query={query}
-                      refetchGroups={this.fetchData}
-                    />
-                  </GroupStatsProvider>
+                  <GroupListBody
+                    memberList={this.state.memberList}
+                    groupStatsPeriod={this.getGroupStatsPeriod()}
+                    groupIds={groupIds}
+                    displayReprocessingLayout={displayReprocessingActions}
+                    query={query}
+                    sort={this.getSort()}
+                    selectedProjectIds={selection.projects}
+                    loading={issuesLoading}
+                    error={error}
+                    refetchGroups={this.fetchData}
+                  />
                 </VisuallyCompleteWithData>
               </PanelBody>
             </Panel>