Browse Source

feat(profiling): profiling onboarding sidebar (#41322)

## Summary

**Important:** This PR depends on:
https://github.com/getsentry/sentry-docs/pull/5778

Adds the new profiling onboarding sidebar behind a
`profiling-onboarding-checklist` feature flag.


Examples:

![image](https://user-images.githubusercontent.com/7349258/201730207-8f81480b-258c-4808-8545-3ef5f3684d81.png)


![image](https://user-images.githubusercontent.com/7349258/201730239-9678f3f3-9f3d-43a4-ab45-7ab8a8fb8123.png)
Elias Hussary 2 years ago
parent
commit
fa2defbffa

+ 13 - 3
build-utils/integration-docs-fetch-plugin.ts

@@ -2,12 +2,16 @@
 /* eslint import/no-nodejs-modules:0 */
 
 import fs from 'fs';
+import http from 'http';
 import https from 'https';
 import path from 'path';
+import url from 'url';
 
 import webpack from 'webpack';
 
-const PLATFORMS_URL = 'https://docs.sentry.io/_platforms/_index.json';
+const INTEGRATIONS_DOC_URL =
+  process.env.INTEGRATION_DOCS_URL || 'https://docs.sentry.io/_platforms/';
+const PLATFORMS_URL = INTEGRATIONS_DOC_URL + '_index.json';
 const DOCS_INDEX_PATH = 'src/sentry/integration-docs/_platforms.json';
 
 const alphaSortFromKey =
@@ -68,8 +72,13 @@ class IntegrationDocsFetchPlugin {
   fetch: Parameters<webpack.Compiler['hooks']['beforeRun']['tapAsync']>[1] = (
     _compilation,
     callback
-  ) =>
-    https
+  ) => {
+    let httpClient = https;
+    if (url.parse(PLATFORMS_URL).protocol === 'http:') {
+      // @ts-ignore
+      httpClient = http;
+    }
+    return httpClient
       .get(PLATFORMS_URL, res => {
         res.setEncoding('utf8');
         let buffer = '';
@@ -88,6 +97,7 @@ class IntegrationDocsFetchPlugin {
           );
       })
       .on('error', callback);
+  };
 
   apply(compiler: webpack.Compiler) {
     compiler.hooks.beforeRun.tapAsync('IntegrationDocsFetchPlugin', this.fetch);

+ 2 - 1
src/sentry/utils/integrationdocs.py

@@ -35,7 +35,8 @@ class Platform(TypedDict):
     integrations: list[dict[str, str]]
 
 
-BASE_URL = "https://docs.sentry.io/_platforms/{}"
+INTEGRATION_DOCS_URL = os.environ.get("INTEGRATION_DOCS_URL", "https://docs.sentry.io/_platforms/")
+BASE_URL = INTEGRATION_DOCS_URL + "{}"
 
 # Also see INTEGRATION_DOC_FOLDER in setup.py
 DOC_FOLDER = os.environ.get("INTEGRATION_DOC_FOLDER") or os.path.abspath(

+ 22 - 3
static/app/components/onboardingWizard/useOnboardingDocs.spec.tsx

@@ -6,6 +6,7 @@ import {
   generateDocKeys,
   isPlatformSupported,
 } from 'sentry/components/performanceOnboarding/utils';
+import {PlatformIntegration} from 'sentry/types';
 import {OrganizationContext} from 'sentry/views/organizationContext';
 
 describe('useOnboardingDocs', function () {
@@ -39,7 +40,13 @@ describe('useOnboardingDocs', function () {
     });
 
     const {result, waitForNextUpdate} = reactHooks.renderHook(useOnboardingDocs, {
-      initialProps: {project, generateDocKeys, isPlatformSupported},
+      initialProps: {
+        project,
+        docKeys,
+        isPlatformSupported: isPlatformSupported({
+          id: project.platform,
+        } as PlatformIntegration),
+      },
       wrapper,
     });
     await waitForNextUpdate();
@@ -87,7 +94,13 @@ describe('useOnboardingDocs', function () {
     });
 
     const {result} = reactHooks.renderHook(useOnboardingDocs, {
-      initialProps: {project, generateDocKeys, isPlatformSupported},
+      initialProps: {
+        project,
+        docKeys,
+        isPlatformSupported: isPlatformSupported({
+          id: project.platform,
+        } as PlatformIntegration),
+      },
       wrapper,
     });
     const {docContents, isLoading, hasOnboardingContents} = result.current;
@@ -130,7 +143,13 @@ describe('useOnboardingDocs', function () {
     });
 
     const {result} = reactHooks.renderHook(useOnboardingDocs, {
-      initialProps: {project, generateDocKeys, isPlatformSupported},
+      initialProps: {
+        project,
+        docKeys,
+        isPlatformSupported: isPlatformSupported({
+          id: project.platform,
+        } as PlatformIntegration),
+      },
       wrapper,
     });
     const {docContents, isLoading, hasOnboardingContents} = result.current;

+ 9 - 12
static/app/components/onboardingWizard/useOnboardingDocs.tsx

@@ -2,9 +2,8 @@ import {useEffect, useState} from 'react';
 import * as Sentry from '@sentry/react';
 
 import {loadDocs} from 'sentry/actionCreators/projects';
-import {PlatformKey} from 'sentry/data/platformCategories';
 import platforms from 'sentry/data/platforms';
-import {PlatformIntegration, Project} from 'sentry/types';
+import {Project} from 'sentry/types';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 
@@ -12,12 +11,12 @@ const INITIAL_LOADING_DOCS = {};
 const INITIAL_DOC_CONTENTS = {};
 
 type Options = {
-  generateDocKeys: (platform: PlatformKey) => string[];
-  isPlatformSupported: (platform: undefined | PlatformIntegration) => boolean;
+  docKeys: string[];
+  isPlatformSupported: boolean;
   project: Project;
 };
 
-function useOnboardingDocs({generateDocKeys, isPlatformSupported, project}: Options) {
+function useOnboardingDocs({docKeys, isPlatformSupported, project}: Options) {
   const organization = useOrganization();
   const api = useApi();
 
@@ -30,11 +29,8 @@ function useOnboardingDocs({generateDocKeys, isPlatformSupported, project}: Opti
     ? platforms.find(p => p.id === project.platform)
     : undefined;
 
-  const isSupported = isPlatformSupported(currentPlatform);
-  const docKeys = currentPlatform && generateDocKeys(currentPlatform.id);
-
   useEffect(() => {
-    if (!isSupported) {
+    if (!isPlatformSupported) {
       if (loadingDocs !== INITIAL_LOADING_DOCS) {
         setLoadingDocs(INITIAL_LOADING_DOCS);
       }
@@ -44,7 +40,7 @@ function useOnboardingDocs({generateDocKeys, isPlatformSupported, project}: Opti
       return;
     }
 
-    docKeys?.forEach(docKey => {
+    docKeys.forEach(docKey => {
       if (docKey in loadingDocs) {
         // If a documentation content is loading, we should not attempt to fetch it again.
         // otherwise, if it's not loading, we should only fetch at most once.
@@ -91,7 +87,7 @@ function useOnboardingDocs({generateDocKeys, isPlatformSupported, project}: Opti
   }, [
     currentPlatform,
     docKeys,
-    isSupported,
+    isPlatformSupported,
     api,
     loadingDocs,
     organization.slug,
@@ -99,7 +95,7 @@ function useOnboardingDocs({generateDocKeys, isPlatformSupported, project}: Opti
     docContents,
   ]);
 
-  if (!currentPlatform || !isSupported) {
+  if (!currentPlatform || !isPlatformSupported) {
     return {
       isLoading: false,
       hasOnboardingContents: false,
@@ -121,6 +117,7 @@ function useOnboardingDocs({generateDocKeys, isPlatformSupported, project}: Opti
   );
 
   return {
+    docKeys,
     isLoading,
     hasOnboardingContents,
     docContents,

+ 7 - 8
static/app/components/performanceOnboarding/sidebar.tsx

@@ -179,20 +179,21 @@ function OnboardingContent({currentProject}: {currentProject: Project}) {
     }
   }, [previousProject.id, currentProject.id]);
 
+  const currentPlatform = currentProject.platform
+    ? platforms.find(p => p.id === currentProject.platform)
+    : undefined;
+
+  const docKeys = currentPlatform ? generateDocKeys(currentPlatform.id) : [];
   const {docContents, isLoading, hasOnboardingContents} = useOnboardingDocs({
     project: currentProject,
-    generateDocKeys,
-    isPlatformSupported,
+    docKeys,
+    isPlatformSupported: isPlatformSupported(currentPlatform),
   });
 
   if (isLoading) {
     return <LoadingIndicator />;
   }
 
-  const currentPlatform = currentProject.platform
-    ? platforms.find(p => p.id === currentProject.platform)
-    : undefined;
-
   const doesNotSupportPerformance = currentProject.platform
     ? withoutPerformanceSupport.has(currentProject.platform)
     : false;
@@ -237,8 +238,6 @@ function OnboardingContent({currentProject}: {currentProject: Project}) {
     );
   }
 
-  const docKeys = generateDocKeys(currentPlatform.id);
-
   return (
     <Fragment>
       <div>

+ 239 - 0
static/app/components/profiling/ProfilingOnboarding/proflingOnboardingSidebar.tsx

@@ -0,0 +1,239 @@
+import React, {Fragment, useEffect, useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import Button from 'sentry/components/button';
+import DropdownMenuControl from 'sentry/components/dropdownMenuControl';
+import {MenuItemProps} from 'sentry/components/dropdownMenuItem';
+import IdBadge from 'sentry/components/idBadge';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import useOnboardingDocs from 'sentry/components/onboardingWizard/useOnboardingDocs';
+import {
+  DocumentationWrapper,
+  OnboardingStep,
+} from 'sentry/components/sidebar/onboardingStep';
+import {TaskSidebar, TaskSidebarList} from 'sentry/components/sidebar/taskSidebar';
+import {CommonSidebarProps, SidebarPanelKey} from 'sentry/components/sidebar/types';
+import platforms from 'sentry/data/platforms';
+import {t, tct} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Project} from 'sentry/types';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useProjects from 'sentry/utils/useProjects';
+
+import {
+  makeDocKeyMap,
+  splitProjectsByProfilingSupport,
+  SupportedProfilingPlatform,
+  supportedProfilingPlatforms,
+} from './util';
+
+export function ProfilingOnboardingSidebar(props: CommonSidebarProps) {
+  const {currentPanel, collapsed, hidePanel, orientation} = props;
+  const isActive = currentPanel === SidebarPanelKey.ProfilingOnboarding;
+  const organization = useOrganization();
+  const hasProjectAccess = organization.access.includes('project:read');
+
+  const {projects, initiallyLoaded: projectsLoaded} = useProjects();
+
+  const [currentProject, setCurrentProject] = useState<Project | undefined>(undefined);
+  const pageFilters = usePageFilters();
+
+  const {supported: supportedProjects} = useMemo(
+    () => splitProjectsByProfilingSupport(projects),
+    [projects]
+  );
+
+  useEffect(() => {
+    if (!projects.length) {
+      return;
+    }
+    const pageProjectSelectionId = pageFilters.selection.projects[0];
+    const pageProjectSelection = projects.find(
+      p => p.id === String(pageProjectSelectionId)
+    );
+    if (pageProjectSelection && supportedProjects.includes(pageProjectSelection)) {
+      setCurrentProject(pageProjectSelection);
+      return;
+    }
+    setCurrentProject(supportedProjects[0]);
+  }, [projects, pageFilters.selection.projects, supportedProjects]);
+
+  if (
+    !isActive ||
+    !hasProjectAccess ||
+    !currentProject ||
+    !projectsLoaded ||
+    !projects ||
+    projects.length === 0
+  ) {
+    return null;
+  }
+
+  const items: MenuItemProps[] = supportedProjects.map(project => {
+    return {
+      key: project.id,
+      label: <StyledIdBadge project={project} avatarSize={16} hideOverflow disableLink />,
+      onAction: function switchProject() {
+        setCurrentProject(project);
+      },
+    };
+  });
+
+  return (
+    <TaskSidebar orientation={orientation} collapsed={collapsed} hidePanel={hidePanel}>
+      <TaskSidebarList>
+        <Heading>{t('Profile Code')}</Heading>
+        <DropdownMenuControl
+          items={items}
+          triggerLabel={
+            <StyledIdBadge
+              project={currentProject}
+              avatarSize={16}
+              hideOverflow
+              disableLink
+            />
+          }
+          triggerProps={{'aria-label': currentProject.slug}}
+          position="bottom-end"
+        />
+        <OnboardingContent currentProject={currentProject} />
+      </TaskSidebarList>
+    </TaskSidebar>
+  );
+}
+
+function OnboardingContent({currentProject}: {currentProject: Project}) {
+  const currentPlatform = platforms.find(p => p.id === currentProject.platform);
+
+  // TODO: implement polling
+  // usePollForFirstProfileEvent();
+
+  const docKeysMap = useMemo(() => makeDocKeyMap(currentPlatform?.id), [currentPlatform]);
+
+  const isPlatformSupported = useMemo(() => {
+    if (!currentPlatform) {
+      return false;
+    }
+    return supportedProfilingPlatforms.includes(
+      // typescript being typescript
+      currentPlatform.id as SupportedProfilingPlatform
+    );
+  }, [currentPlatform]);
+
+  const {docContents, isLoading, hasOnboardingContents} = useOnboardingDocs({
+    docKeys: docKeysMap ? Object.values(docKeysMap) : [],
+    project: currentProject,
+    isPlatformSupported,
+  });
+
+  if (isLoading) {
+    return <LoadingIndicator />;
+  }
+
+  if (!isPlatformSupported) {
+    return (
+      <Fragment>
+        <div>
+          {tct(
+            'Fiddlesticks. Profiling isn’t available for your [platform] project yet but we’re definitely still working on it. Stay tuned.',
+            {platform: currentPlatform?.name || currentProject.slug}
+          )}
+        </div>
+        <div>
+          <Button size="sm" href="https://docs.sentry.io/platforms/" external>
+            {t('Go to Sentry Documentation')}
+          </Button>
+        </div>
+      </Fragment>
+    );
+  }
+
+  if (!currentPlatform || !docKeysMap || !hasOnboardingContents) {
+    return (
+      <Fragment>
+        <div>
+          {tct(
+            'Fiddlesticks. This checklist isn’t available for your [project] project yet, but for now, go to Sentry docs for installation details.',
+            {project: currentProject.slug}
+          )}
+        </div>
+        <div>
+          <Button
+            size="sm"
+            href="https://docs.sentry.io/product/performance/getting-started/"
+            external
+          >
+            {t('Go to documentation')}
+          </Button>
+        </div>
+      </Fragment>
+    );
+  }
+
+  const alertContent = docContents[docKeysMap['0-alert']];
+
+  return (
+    <Fragment>
+      {alertContent && (
+        <DocumentationWrapper dangerouslySetInnerHTML={{__html: alertContent}} />
+      )}
+      <p>
+        {t(
+          `Adding Profiling to your %s project is simple. Make sure you've got these basics down.`,
+          currentPlatform!.name
+        )}
+      </p>
+      {Object.entries(docKeysMap).map(entry => {
+        const [key, docKey] = entry;
+        if (key === '0-alert') {
+          return null;
+        }
+
+        const content = docContents[docKey];
+        if (!content) {
+          return null;
+        }
+        return (
+          <div key={docKey}>
+            <OnboardingStep
+              prefix="profiling"
+              docKey={docKey}
+              project={currentProject}
+              docContent={content}
+            />
+          </div>
+        );
+      })}
+
+      {/* <EventWaitingIndicator /> */}
+    </Fragment>
+  );
+}
+
+// TODO: implement poll for first profile event
+// function usePollForFirstProfileEvent() {
+//   // TODO: implement polling on onboarding endpoint
+// }
+
+// const EventWaitingIndicator = () => (
+//   <EventIndicator status="waiting">
+//     {t("Waiting for this project's first profile")}
+//   </EventIndicator>
+// );
+
+const Heading = styled('div')`
+  display: flex;
+  color: ${p => p.theme.purple300};
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  text-transform: uppercase;
+  font-weight: 600;
+  line-height: 1;
+  margin-top: ${space(3)};
+`;
+
+const StyledIdBadge = styled(IdBadge)`
+  overflow: hidden;
+  white-space: nowrap;
+  flex-shrink: 1;
+`;

+ 77 - 0
static/app/components/profiling/ProfilingOnboarding/util.ts

@@ -0,0 +1,77 @@
+import {PlatformKey} from 'sentry/data/platformCategories';
+import {Project} from 'sentry/types/project';
+
+export const supportedProfilingPlatforms = [
+  'android',
+  'apple-ios',
+  'node',
+  'python',
+] as const;
+
+export type SupportedProfilingPlatform = Extract<
+  PlatformKey,
+  typeof supportedProfilingPlatforms[number]
+>;
+
+export const profilingOnboardingDocKeys = [
+  '0-alert',
+  '1-install',
+  '2-configure-performance',
+  '3-configure-profiling',
+  '4-upload',
+] as const;
+
+type ProfilingOnboardingDocKeys = typeof profilingOnboardingDocKeys[number];
+
+export const supportedPlatformExpectedDocKeys: Record<
+  SupportedProfilingPlatform,
+  ProfilingOnboardingDocKeys[]
+> = {
+  android: ['1-install', '2-configure-performance', '3-configure-profiling', '4-upload'],
+  'apple-ios': [
+    '1-install',
+    '2-configure-performance',
+    '3-configure-profiling',
+    '4-upload',
+  ],
+  node: ['0-alert', '1-install', '2-configure-performance', '3-configure-profiling'],
+  python: ['0-alert', '1-install', '2-configure-performance', '3-configure-profiling'],
+};
+
+function makeDocKey(platformId: PlatformKey, key: string) {
+  return `${platformId}-profiling-onboarding-${key}`;
+}
+
+type DocKeyMap = Record<typeof profilingOnboardingDocKeys[number], string>;
+export function makeDocKeyMap(platformId: PlatformKey | undefined) {
+  if (!platformId) {
+    return null;
+  }
+  const expectedDocKeys: ProfilingOnboardingDocKeys[] =
+    supportedPlatformExpectedDocKeys[platformId];
+  return expectedDocKeys.reduce((acc: DocKeyMap, key) => {
+    acc[key] = makeDocKey(platformId, key);
+    return acc;
+  }, {} as DocKeyMap);
+}
+
+export function splitProjectsByProfilingSupport(projects: Project[]): {
+  supported: Project[];
+  unsupported: Project[];
+} {
+  const supported: Project[] = [];
+  const unsupported: Project[] = [];
+
+  for (const project of projects) {
+    if (
+      project.platform &&
+      supportedProfilingPlatforms.includes(project.platform as SupportedProfilingPlatform)
+    ) {
+      supported.push(project);
+    } else {
+      unsupported.push(project);
+    }
+  }
+
+  return {supported, unsupported};
+}

+ 8 - 8
static/app/components/replaysOnboarding/sidebar.tsx

@@ -101,20 +101,22 @@ function OnboardingContent({currentProject}: {currentProject: Project}) {
     }
   }, [previousProject.id, currentProject.id]);
 
+  const currentPlatform = currentProject.platform
+    ? platforms.find(p => p.id === currentProject.platform)
+    : undefined;
+
+  const docKeys = currentPlatform ? generateDocKeys(currentPlatform.id) : [];
+
   const {docContents, isLoading, hasOnboardingContents} = useOnboardingDocs({
     project: currentProject,
-    generateDocKeys,
-    isPlatformSupported,
+    docKeys,
+    isPlatformSupported: isPlatformSupported(currentPlatform),
   });
 
   if (isLoading) {
     return <LoadingIndicator />;
   }
 
-  const currentPlatform = currentProject.platform
-    ? platforms.find(p => p.id === currentProject.platform)
-    : undefined;
-
   const doesNotSupportReplay = currentProject.platform
     ? !replayPlatforms.includes(currentProject.platform)
     : true;
@@ -159,8 +161,6 @@ function OnboardingContent({currentProject}: {currentProject: Project}) {
     );
   }
 
-  const docKeys = generateDocKeys(currentPlatform.id);
-
   return (
     <Fragment>
       <div>

+ 8 - 0
static/app/components/sidebar/index.tsx

@@ -40,6 +40,8 @@ import {getDiscoverLandingUrl} from 'sentry/utils/discover/urls';
 import theme from 'sentry/utils/theme';
 import useMedia from 'sentry/utils/useMedia';
 
+import {ProfilingOnboardingSidebar} from '../profiling/ProfilingOnboarding/proflingOnboardingSidebar';
+
 import Broadcasts from './broadcasts';
 import SidebarHelp from './help';
 import OnboardingStatus from './onboardingStatus';
@@ -378,6 +380,12 @@ function Sidebar({location, organization}: Props) {
             hidePanel={hidePanel}
             {...sidebarItemProps}
           />
+          <ProfilingOnboardingSidebar
+            currentPanel={activePanel}
+            onShowPanel={() => togglePanel(SidebarPanelKey.ReplaysOnboarding)}
+            hidePanel={hidePanel}
+            {...sidebarItemProps}
+          />
           <SidebarSection noMargin noPadding>
             <OnboardingStatus
               org={organization}

+ 3 - 3
static/app/components/sidebar/onboardingStep.tsx

@@ -15,7 +15,7 @@ type Props = {
   project: Project;
 };
 
-function OnBoardingStep({docContent, docKey, prefix, project}: Props) {
+export function OnboardingStep({docContent, docKey, prefix, project}: Props) {
   const [increment, setIncrement] = useState<number>(0);
 
   if (!docContent) {
@@ -68,7 +68,7 @@ const TaskCheckBox = styled('div')`
   position: relative;
 `;
 
-const DocumentationWrapper = styled('div')`
+export const DocumentationWrapper = styled('div')`
   line-height: 1.5;
 
   .gatsby-highlight {
@@ -104,4 +104,4 @@ const DocumentationWrapper = styled('div')`
   position: relative;
 `;
 
-export default OnBoardingStep;
+export default OnboardingStep;

Some files were not shown because too many files changed in this diff