Browse Source

ref(profiling): Use request state for the profiling hooks (#35006)

Clean up existing hooks and add new hooks in profiling components to use the
request state type.
Tony Xiao 2 years ago
parent
commit
35d140d0b1

+ 109 - 0
static/app/utils/profiling/hooks/useFunctions.tsx

@@ -0,0 +1,109 @@
+import {useEffect, useState} from 'react';
+import * as Sentry from '@sentry/react';
+
+import {Client} from 'sentry/api';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import {t} from 'sentry/locale';
+import {Organization, PageFilters, Project, RequestState} from 'sentry/types';
+import {FunctionCall} from 'sentry/types/profiling/core';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface UseFunctionsOptions {
+  project: Project;
+  query: string;
+  transaction: string;
+  version: string;
+  selection?: PageFilters;
+}
+
+function useFunctions({
+  project,
+  query,
+  transaction,
+  version,
+  selection,
+}: UseFunctionsOptions): RequestState<FunctionCall[]> {
+  const api = useApi();
+  const organization = useOrganization();
+
+  const [requestState, setRequestState] = useState<RequestState<FunctionCall[]>>({
+    type: 'initial',
+  });
+
+  useEffect(() => {
+    if (selection === undefined) {
+      return undefined;
+    }
+
+    setRequestState({type: 'loading'});
+
+    fetchFunctions(api, organization, {
+      projectSlug: project.slug,
+      query,
+      selection,
+      transaction,
+      version,
+    })
+      .then(functions => {
+        setRequestState({
+          type: 'resolved',
+          data: functions.Versions[version]?.FunctionCalls ?? [],
+        });
+      })
+      .catch(err => {
+        setRequestState({type: 'errored', error: t('Error: Unable to load functions')});
+        Sentry.captureException(err);
+      });
+
+    return () => api.clear();
+  }, [
+    api,
+    organization,
+    project.slug,
+    query,
+    selection,
+    transaction,
+    version,
+    setRequestState,
+  ]);
+
+  return requestState;
+}
+
+function fetchFunctions(
+  api: Client,
+  organization: Organization,
+  {
+    projectSlug,
+    query,
+    selection,
+    transaction,
+    version,
+  }: {
+    projectSlug: Project['slug'];
+    query: string;
+    selection: PageFilters;
+    transaction: string;
+    version: string;
+  }
+) {
+  const conditions = new MutableSearch(query);
+  conditions.setFilterValues('transaction_name', [transaction]);
+  conditions.setFilterValues('version', [version]);
+
+  return api.requestPromise(
+    `/projects/${organization.slug}/${projectSlug}/profiling/functions/`,
+    {
+      method: 'GET',
+      includeAllArgs: false,
+      query: {
+        environment: selection.environments,
+        ...normalizeDateTimeParams(selection.datetime),
+        query: conditions.formatString(),
+      },
+    }
+  );
+}
+export {useFunctions};

+ 10 - 3
static/app/utils/profiling/hooks/useProfileFilters.tsx

@@ -8,7 +8,12 @@ import useOrganization from 'sentry/utils/useOrganization';
 
 type ProfileFilters = Record<string, Tag>;
 
-function useProfileFilters(selection: PageFilters | undefined): ProfileFilters {
+interface ProfileFiltersOptions {
+  query: string;
+  selection?: PageFilters;
+}
+
+function useProfileFilters({query, selection}: ProfileFiltersOptions): ProfileFilters {
   const api = useApi();
   const organization = useOrganization();
 
@@ -19,7 +24,7 @@ function useProfileFilters(selection: PageFilters | undefined): ProfileFilters {
       return undefined;
     }
 
-    fetchProfileFilters(api, organization, selection).then(response => {
+    fetchProfileFilters(api, organization, query, selection).then(response => {
       const withPredefinedFilters = response.reduce(
         (filters: ProfileFilters, tag: Tag) => {
           filters[tag.key] = {
@@ -36,7 +41,7 @@ function useProfileFilters(selection: PageFilters | undefined): ProfileFilters {
     });
 
     return () => api.clear();
-  }, [api, organization, selection]);
+  }, [api, organization, query, selection]);
 
   return profileFilters;
 }
@@ -44,11 +49,13 @@ function useProfileFilters(selection: PageFilters | undefined): ProfileFilters {
 function fetchProfileFilters(
   api: Client,
   organization: Organization,
+  query: string,
   selection: PageFilters
 ): Promise<[Tag]> {
   return api.requestPromise(`/organizations/${organization.slug}/profiling/filters/`, {
     method: 'GET',
     query: {
+      query,
       project: selection.projects,
       environment: selection.environments,
       ...normalizeDateTimeParams(selection.datetime),

+ 62 - 39
static/app/utils/profiling/hooks/useProfiles.tsx

@@ -1,71 +1,94 @@
 import {useEffect, useState} from 'react';
+import * as Sentry from '@sentry/react';
 
-import {Client, ResponseMeta} from 'sentry/api';
+import {Client} from 'sentry/api';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
-import {Organization, PageFilters} from 'sentry/types';
+import {t} from 'sentry/locale';
+import {Organization, PageFilters, RequestState} from 'sentry/types';
 import {Trace} from 'sentry/types/profiling/core';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 
-type RequestState = 'initial' | 'loading' | 'resolved' | 'errored';
-
-function fetchTraces(
-  api: Client,
-  query: string | undefined,
-  cursor: string | undefined,
-  organization: Organization,
-  selection: PageFilters
-): Promise<[Trace[], string | undefined, ResponseMeta | undefined]> {
-  return api.requestPromise(`/organizations/${organization.slug}/profiling/profiles/`, {
-    method: 'GET',
-    includeAllArgs: true,
-    query: {
-      cursor,
-      query,
-      project: selection.projects,
-      environment: selection.environments,
-      ...normalizeDateTimeParams(selection.datetime),
-    },
-  });
-}
+type ProfilesResult = {
+  pageLinks: string | null;
+  traces: Trace[];
+};
 
 interface UseProfilesOptions {
-  cursor: string | undefined;
-  query: string | undefined;
-  selection: PageFilters | undefined;
+  query: string;
+  cursor?: string;
+  limit?: number;
+  selection?: PageFilters;
 }
 
 function useProfiles({
   cursor,
+  limit,
   query,
   selection,
-}: UseProfilesOptions): [RequestState, Trace[], string | null] {
+}: UseProfilesOptions): RequestState<ProfilesResult> {
   const api = useApi();
   const organization = useOrganization();
 
-  const [requestState, setRequestState] = useState<RequestState>('initial');
-  const [traces, setTraces] = useState<Trace[]>([]);
-  const [pageLinks, setPageLinks] = useState<string | null>(null);
+  const [requestState, setRequestState] = useState<RequestState<ProfilesResult>>({
+    type: 'initial',
+  });
 
   useEffect(() => {
     if (selection === undefined) {
       return undefined;
     }
 
-    setRequestState('loading');
+    setRequestState({type: 'loading'});
 
-    fetchTraces(api, query, cursor, organization, selection)
-      .then(([_traces, , response]) => {
-        setTraces(_traces);
-        setPageLinks(response?.getResponseHeader('Link') ?? null);
-        setRequestState('resolved');
+    fetchTraces(api, organization, {cursor, limit, query, selection})
+      .then(([traces, , response]) => {
+        setRequestState({
+          type: 'resolved',
+          data: {
+            traces,
+            pageLinks: response?.getResponseHeader('Link') ?? null,
+          },
+        });
       })
-      .catch(() => setRequestState('errored'));
+      .catch(err => {
+        setRequestState({type: 'errored', error: t('Error: Unable to load profiles')});
+        Sentry.captureException(err);
+      });
 
     return () => api.clear();
-  }, [api, query, cursor, organization, selection]);
+  }, [api, organization, cursor, limit, query, selection]);
 
-  return [requestState, traces, pageLinks];
+  return requestState;
+}
+
+function fetchTraces(
+  api: Client,
+  organization: Organization,
+  {
+    cursor,
+    limit,
+    query,
+    selection,
+  }: {
+    cursor: string | undefined;
+    limit: number | undefined;
+    query: string;
+    selection: PageFilters;
+  }
+) {
+  return api.requestPromise(`/organizations/${organization.slug}/profiling/profiles/`, {
+    method: 'GET',
+    includeAllArgs: true,
+    query: {
+      cursor,
+      query,
+      per_page: limit,
+      project: selection.projects,
+      environment: selection.environments,
+      ...normalizeDateTimeParams(selection.datetime),
+    },
+  });
 }
 
 export {useProfiles};

+ 13 - 9
static/app/views/profiling/content.tsx

@@ -38,8 +38,8 @@ function ProfilingContent({location, selection}: ProfilingContentProps) {
   const organization = useOrganization();
   const cursor = decodeScalar(location.query.cursor);
   const query = decodeScalar(location.query.query, '');
-  const profileFilters = useProfileFilters(selection);
-  const [requestState, traces, pageLinks] = useProfiles({cursor, query, selection});
+  const profileFilters = useProfileFilters({query: '', selection});
+  const profiles = useProfiles({cursor, query, selection});
 
   const handleSearch: SmartSearchBarProps['onSearch'] = useCallback(
     (searchQuery: string) => {
@@ -83,7 +83,7 @@ function ProfilingContent({location, selection}: ProfilingContentProps) {
                     maxQueryLength={MAX_QUERY_LENGTH}
                   />
                 </ActionBar>
-                {requestState === 'errored' && (
+                {profiles.type === 'errored' && (
                   <Alert type="error" showIcon>
                     {t('Unable to load profiles')}
                   </Alert>
@@ -97,16 +97,20 @@ function ProfilingContent({location, selection}: ProfilingContentProps) {
                       utc: null,
                     }
                   }
-                  traces={traces}
-                  isLoading={requestState === 'loading'}
+                  traces={profiles.type === 'resolved' ? profiles.data.traces : []}
+                  isLoading={profiles.type === 'loading'}
                 />
                 <ProfilingTable
-                  isLoading={requestState === 'loading'}
-                  error={requestState === 'errored' ? t('Unable to load profiles') : null}
+                  isLoading={profiles.type === 'initial' || profiles.type === 'loading'}
+                  error={profiles.type === 'errored' ? profiles.error : null}
                   location={location}
-                  traces={traces}
+                  traces={profiles.type === 'resolved' ? profiles.data.traces : []}
+                />
+                <Pagination
+                  pageLinks={
+                    profiles.type === 'resolved' ? profiles.data.pageLinks : null
+                  }
                 />
-                <Pagination pageLinks={pageLinks} />
               </Layout.Main>
             </Layout.Body>
           </StyledPageContent>

+ 82 - 0
tests/js/spec/utils/profiling/hooks/useFunctions.spec.tsx

@@ -0,0 +1,82 @@
+import {ReactElement, useMemo} from 'react';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {reactHooks} from 'sentry-test/reactTestingLibrary';
+
+import {PageFilters} from 'sentry/types';
+import {useFunctions} from 'sentry/utils/profiling/hooks/useFunctions';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+
+const project = TestStubs.Project();
+
+const selection: PageFilters = {
+  datetime: {
+    period: '14d',
+    utc: null,
+    start: null,
+    end: null,
+  },
+  environments: [],
+  projects: [],
+};
+
+function TestContext({children}: {children: ReactElement}) {
+  const {organization} = useMemo(() => initializeOrg(), []);
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      {children}
+    </OrganizationContext.Provider>
+  );
+}
+
+describe('useFunctions', function () {
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('initializes with the loading state', function () {
+    const hook = reactHooks.renderHook(
+      () =>
+        useFunctions({
+          project,
+          query: '',
+          transaction: '',
+          version: '',
+        }),
+      {wrapper: TestContext}
+    );
+    expect(hook.result.current).toEqual({type: 'initial'});
+  });
+
+  it('fetches functions', async function () {
+    MockApiClient.addMockResponse({
+      url: `/projects/org-slug/${project.slug}/profiling/functions/`,
+      body: {
+        Versions: {
+          '': {
+            FunctionCalls: [],
+          },
+        },
+      },
+    });
+
+    const hook = reactHooks.renderHook(
+      () =>
+        useFunctions({
+          project,
+          query: '',
+          transaction: '',
+          version: '',
+          selection,
+        }),
+      {wrapper: TestContext}
+    );
+    expect(hook.result.current).toEqual({type: 'loading'});
+    await hook.waitForNextUpdate();
+    expect(hook.result.current).toEqual({
+      type: 'resolved',
+      data: [],
+    });
+  });
+});

+ 59 - 0
tests/js/spec/utils/profiling/hooks/useProfiles.spec.tsx

@@ -0,0 +1,59 @@
+import {ReactElement, useMemo} from 'react';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {reactHooks} from 'sentry-test/reactTestingLibrary';
+
+import {PageFilters} from 'sentry/types';
+import {useProfiles} from 'sentry/utils/profiling/hooks/useProfiles';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+
+const selection: PageFilters = {
+  datetime: {
+    period: '14d',
+    utc: null,
+    start: null,
+    end: null,
+  },
+  environments: [],
+  projects: [],
+};
+
+function TestContext({children}: {children: ReactElement}) {
+  const {organization} = useMemo(() => initializeOrg(), []);
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      {children}
+    </OrganizationContext.Provider>
+  );
+}
+
+describe('useProfiles', function () {
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('initializes with the initial state', function () {
+    const hook = reactHooks.renderHook(() => useProfiles({query: ''}), {
+      wrapper: TestContext,
+    });
+    expect(hook.result.current).toEqual({type: 'initial'});
+  });
+
+  it('fetches profiles', async function () {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/profiling/profiles/',
+      body: [],
+    });
+
+    const hook = reactHooks.renderHook(() => useProfiles({query: '', selection}), {
+      wrapper: TestContext,
+    });
+    expect(hook.result.current).toEqual({type: 'loading'});
+    await hook.waitForNextUpdate();
+    expect(hook.result.current).toEqual({
+      type: 'resolved',
+      data: {traces: [], pageLinks: null},
+    });
+  });
+});