Browse Source

feat(profiling) fetch continuous profile chunk (#74015)

Add fetching of continuous profile chunk
Jonas 8 months ago
parent
commit
2d8b32fcae

+ 50 - 0
static/app/components/profiling/continuousProfileHeader.tsx

@@ -0,0 +1,50 @@
+import {useMemo} from 'react';
+import styled from '@emotion/styled';
+
+import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
+import * as Layout from 'sentry/components/layouts/thirds';
+import type {ProfilingBreadcrumbsProps} from 'sentry/components/profiling/profilingBreadcrumbs';
+import {ProfilingBreadcrumbs} from 'sentry/components/profiling/profilingBreadcrumbs';
+import {space} from 'sentry/styles/space';
+import useOrganization from 'sentry/utils/useOrganization';
+
+export function ContinuousProfileHeader() {
+  const organization = useOrganization();
+  // @TODO add breadcrumbs when other views are implemented
+  const breadCrumbs = useMemo((): ProfilingBreadcrumbsProps['trails'] => {
+    return [{type: 'landing', payload: {query: {}}}];
+  }, []);
+
+  return (
+    <SmallerLayoutHeader>
+      <SmallerHeaderContent>
+        <SmallerProfilingBreadcrumbsWrapper>
+          <ProfilingBreadcrumbs organization={organization} trails={breadCrumbs} />
+        </SmallerProfilingBreadcrumbsWrapper>
+      </SmallerHeaderContent>
+      <StyledHeaderActions>
+        <FeedbackWidgetButton />
+      </StyledHeaderActions>
+    </SmallerLayoutHeader>
+  );
+}
+
+const StyledHeaderActions = styled(Layout.HeaderActions)`
+  display: flex;
+  flex-direction: row;
+  gap: ${space(1)};
+`;
+
+const SmallerHeaderContent = styled(Layout.HeaderContent)`
+  margin-bottom: ${space(1.5)};
+`;
+
+const SmallerProfilingBreadcrumbsWrapper = styled('div')`
+  nav {
+    padding-bottom: ${space(1)};
+  }
+`;
+
+const SmallerLayoutHeader = styled(Layout.Header)`
+  padding: ${space(1)} ${space(2)} ${space(0)} ${space(2)} !important;
+`;

+ 8 - 10
static/app/routes.tsx

@@ -1961,25 +1961,23 @@ function buildRoutes() {
         component={make(() => import('sentry/views/profiling/differentialFlamegraph'))}
       />
       <Route
-        path="profile/:projectId/:eventId/"
-        component={make(() => import('sentry/views/profiling/profilesProvider'))}
+        path="profile/:projectId/"
+        component={make(() => import('sentry/views/profiling/continuousProfileProvider'))}
       >
         <Route
           path="flamegraph/"
-          component={make(() => import('sentry/views/profiling/profileFlamechart'))}
+          component={make(
+            () => import('sentry/views/profiling/continuousProfileFlamechart')
+          )}
         />
       </Route>
       <Route
-        path="profile/:projectId/"
-        component={make(
-          () => import('sentry/views/profiling/continuousProfilesProvider')
-        )}
+        path="profile/:projectId/:eventId/"
+        component={make(() => import('sentry/views/profiling/profilesProvider'))}
       >
         <Route
           path="flamegraph/"
-          component={make(
-            () => import('sentry/views/profiling/continuousProfileFlamechart')
-          )}
+          component={make(() => import('sentry/views/profiling/profileFlamechart'))}
         />
       </Route>
     </Route>

+ 70 - 0
static/app/views/profiling/continuousProfileProvider.spec.tsx

@@ -0,0 +1,70 @@
+import * as Sentry from '@sentry/react';
+import {uuid4} from '@sentry/utils';
+import * as qs from 'query-string';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {ProjectFixture} from 'sentry-fixture/project';
+
+import {render, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+
+import ContinuosProfileProvider from './continuousProfileProvider';
+
+describe('ContinuousProfileProvider', () => {
+  beforeEach(() => {
+    window.location.search = '';
+    MockApiClient.clearMockResponses();
+  });
+  it('fetches chunk', async () => {
+    const project = ProjectFixture();
+    const organization = OrganizationFixture();
+    ProjectsStore.loadInitialData([project]);
+
+    window.location.search = qs.stringify({
+      start: new Date().toISOString(),
+      end: new Date().toISOString(),
+      profilerId: uuid4(),
+    });
+
+    const captureMessage = jest.spyOn(Sentry, 'captureMessage');
+    const chunkRequest = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/profiling/chunks/`,
+      body: {},
+    });
+
+    render(<ContinuosProfileProvider>{null}</ContinuosProfileProvider>, {
+      router: {
+        params: {orgId: organization.slug, projectId: project.slug},
+      },
+      organization,
+    });
+
+    await waitFor(() => expect(chunkRequest).toHaveBeenCalled());
+    expect(captureMessage).not.toHaveBeenCalled();
+  });
+
+  it('requires start, end and profilerId', async () => {
+    for (const [start, end, profilerId] of [
+      [undefined, new Date().toISOString(), uuid4()],
+      [new Date().toISOString(), undefined, uuid4()],
+      [new Date().toISOString(), new Date().toISOString(), undefined],
+    ]) {
+      window.location.search = qs.stringify({start, end, profilerId});
+      const captureMessage = jest.spyOn(Sentry, 'captureMessage');
+      render(<ContinuosProfileProvider>{null}</ContinuosProfileProvider>, {
+        router: {
+          params: {orgId: OrganizationFixture().slug, projectId: ProjectFixture().slug},
+        },
+        organization: OrganizationFixture(),
+      });
+
+      await waitFor(() =>
+        expect(captureMessage).toHaveBeenCalledWith(
+          expect.stringContaining(
+            'Failed to fetch continuous profile - invalid query parameters.'
+          )
+        )
+      );
+    }
+  });
+});

+ 134 - 0
static/app/views/profiling/continuousProfileProvider.tsx

@@ -0,0 +1,134 @@
+import {createContext, useContext, useLayoutEffect, useState} from 'react';
+import * as Sentry from '@sentry/react';
+
+import type {Client} from 'sentry/api';
+import {ContinuousProfileHeader} from 'sentry/components/profiling/continuousProfileHeader';
+import type {RequestState} from 'sentry/types/core';
+import type {Organization} from 'sentry/types/organization';
+import type {Project} from 'sentry/types/project';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useParams} from 'sentry/utils/useParams';
+import useProjects from 'sentry/utils/useProjects';
+
+interface ContinuousProfileQueryParams {
+  end: string;
+  profiler_id: string;
+  start: string;
+}
+
+function fetchContinuousProfileFlamegraph(
+  api: Client,
+  query: ContinuousProfileQueryParams,
+  projectSlug: Project['slug'],
+  orgSlug: Organization['slug']
+): Promise<Profiling.ProfileInput> {
+  return api
+    .requestPromise(`/organizations/${orgSlug}/profiling/chunks/`, {
+      method: 'GET',
+      query: {
+        ...query,
+        project: projectSlug,
+      },
+      includeAllArgs: true,
+    })
+    .then(([data]) => data);
+}
+
+type ContinuousProfileProviderValue = RequestState<Profiling.ProfileInput>;
+export const ContinuousProfileContext =
+  createContext<ContinuousProfileProviderValue | null>(null);
+
+export function useContinuousProfile() {
+  const context = useContext(ContinuousProfileContext);
+  if (!context) {
+    throw new Error('useContinuousProfile was called outside of ProfileProvider');
+  }
+  return context;
+}
+
+function isValidDate(date: string): boolean {
+  return !isNaN(Date.parse(date));
+}
+
+function getContinuousChunkQueryParams(
+  query: string
+): ContinuousProfileQueryParams | null {
+  const qs = new URLSearchParams(query);
+  const start = qs.get('start');
+  const end = qs.get('end');
+  const profiler_id = qs.get('profilerId');
+
+  if (!start || !end || !profiler_id) {
+    return null;
+  }
+
+  if (!isValidDate(start) || !isValidDate(end)) {
+    return null;
+  }
+
+  return {
+    start,
+    end,
+    profiler_id,
+  };
+}
+
+interface ContinuousFlamegraphViewProps {
+  children: React.ReactNode;
+}
+
+function ContinuousProfileProvider(
+  props: ContinuousFlamegraphViewProps
+): React.ReactElement {
+  const api = useApi();
+  const params = useParams();
+  const organization = useOrganization();
+  const projects = useProjects();
+
+  const [profiles, setProfiles] = useState<RequestState<Profiling.ProfileInput>>({
+    type: 'initial',
+  });
+
+  useLayoutEffect(() => {
+    if (!params.projectId) {
+      return undefined;
+    }
+
+    const chunkParams = getContinuousChunkQueryParams(window.location.search);
+    const project = projects.projects.find(p => p.slug === params.projectId);
+
+    if (!chunkParams) {
+      Sentry.captureMessage(
+        'Failed to fetch continuous profile - invalid query parameters.'
+      );
+      return undefined;
+    }
+    if (!project) {
+      Sentry.captureMessage('Failed to fetch continuous profile - project not found.');
+      return undefined;
+    }
+
+    setProfiles({type: 'loading'});
+
+    fetchContinuousProfileFlamegraph(api, chunkParams, project.id, organization.slug)
+      .then(p => {
+        setProfiles({type: 'resolved', data: p});
+      })
+      .catch(err => {
+        setProfiles({type: 'errored', error: 'Failed to fetch profiles'});
+        Sentry.captureException(err);
+      });
+
+    return () => api.clear();
+  }, [api, organization.slug, projects.projects, params.projectId]);
+
+  return (
+    <ContinuousProfileContext.Provider value={profiles}>
+      <ContinuousProfileHeader />
+      {props.children}
+    </ContinuousProfileContext.Provider>
+  );
+}
+
+export default ContinuousProfileProvider;

+ 0 - 8
static/app/views/profiling/continuousProfilesProvider.tsx

@@ -1,8 +0,0 @@
-interface ContinuousProfilesProviderProps {
-  children: React.ReactNode;
-}
-export default function ContinuousProfilesProvider({
-  children,
-}: ContinuousProfilesProviderProps) {
-  return children;
-}

+ 2 - 2
static/app/views/profiling/profilesProvider.tsx

@@ -1,4 +1,4 @@
-import {createContext, useContext, useEffect, useState} from 'react';
+import {createContext, useContext, useLayoutEffect, useState} from 'react';
 import * as Sentry from '@sentry/react';
 
 import type {Client} from 'sentry/api';
@@ -139,7 +139,7 @@ export function ProfilesProvider({
     type: 'initial',
   });
 
-  useEffect(() => {
+  useLayoutEffect(() => {
     if (!profileId || !projectSlug || !orgSlug) {
       return undefined;
     }