profilingOnboardingModal.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {PlatformIcon} from 'platformicons';
  4. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  5. import Button, {ButtonPropsWithoutAriaLabel} from 'sentry/components/button';
  6. import {CodeSnippet} from 'sentry/components/codeSnippet';
  7. import {SelectField} from 'sentry/components/forms';
  8. import {SelectFieldProps} from 'sentry/components/forms/selectField';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import Link from 'sentry/components/links/link';
  11. import List from 'sentry/components/list';
  12. import Tag from 'sentry/components/tag';
  13. import {IconOpen} from 'sentry/icons';
  14. import {t, tct} from 'sentry/locale';
  15. import space from 'sentry/styles/space';
  16. import {Organization, UpdateSdkSuggestion} from 'sentry/types';
  17. import {RequestState} from 'sentry/types/core';
  18. import {Project, ProjectSdkUpdates} from 'sentry/types/project';
  19. import {semverCompare} from 'sentry/utils/profiling/units/versions';
  20. import useProjects from 'sentry/utils/useProjects';
  21. import {useProjectSdkUpdates} from 'sentry/utils/useProjectSdkUpdates';
  22. // This is just a doubly linked list of steps
  23. interface OnboardingStep {
  24. current: React.ComponentType<OnboardingStepProps>;
  25. next: OnboardingStep | null;
  26. previous: OnboardingStep | null;
  27. }
  28. type OnboardingRouterState = [OnboardingStep, (step: OnboardingStep | null) => void];
  29. function useOnboardingRouter(initialStep: OnboardingStep): OnboardingRouterState {
  30. const [state, setState] = useState(initialStep);
  31. const toStep = useCallback((nextStep: OnboardingStep | null) => {
  32. // For ergonomics, else we need to move everything to consts so that typescript can infer non nullable types
  33. if (nextStep === null) {
  34. return;
  35. }
  36. setState(current => {
  37. const next = {...nextStep, next: null, previous: current};
  38. // Add the edges between the old and the new step
  39. current.next = next;
  40. next.previous = current;
  41. // Return the next step
  42. return next;
  43. });
  44. }, []);
  45. return [state, toStep];
  46. }
  47. // The wrapper component for all of the onboarding steps. Keeps track of the current step
  48. // and all state. This ensures that moving from step to step does not require users to redo their actions
  49. // and each step can just re-initialize with the values that the user has already selected.
  50. interface ProfilingOnboardingModalProps extends ModalRenderProps {
  51. organization: Organization;
  52. }
  53. export function ProfilingOnboardingModal(props: ProfilingOnboardingModalProps) {
  54. const [state, toStep] = useOnboardingRouter({
  55. previous: null,
  56. current: SelectProjectStep,
  57. next: null,
  58. });
  59. const [project, setProject] = useState<Project | null>(null);
  60. return (
  61. <state.current
  62. {...props}
  63. toStep={toStep}
  64. step={state}
  65. project={project}
  66. setProject={setProject}
  67. />
  68. );
  69. }
  70. // Generate an option for the select field from project
  71. function asSelectOption(
  72. project: Project,
  73. options: {disabled: boolean}
  74. ): SelectFieldProps<Project>['options'][0]['options'] {
  75. return {
  76. label: project.name,
  77. value: project,
  78. disabled: options.disabled,
  79. leadingItems: project.platform ? <PlatformIcon platform={project.platform} /> : null,
  80. };
  81. }
  82. const platformToInstructionsMapping: Record<
  83. string,
  84. React.ComponentType<OnboardingStepProps>
  85. > = {
  86. android: AndroidSendDebugFilesInstruction,
  87. 'apple-ios': IOSSendDebugFilesInstruction,
  88. };
  89. // Splits a list of projects into supported and unsuported list
  90. function splitProjectsByProfilingSupport(projects: Project[]): {
  91. supported: Project[];
  92. unsupported: Project[];
  93. } {
  94. const supported: Project[] = [];
  95. const unsupported: Project[] = [];
  96. for (const project of projects) {
  97. if (project.platform && platformToInstructionsMapping[project.platform]) {
  98. supported.push(project);
  99. } else {
  100. unsupported.push(project);
  101. }
  102. }
  103. return {supported, unsupported};
  104. }
  105. // Individual modal steps are defined here.
  106. // We proxy the modal props to each individaul modal component
  107. // so that each can build their own modal and they can remain independent.
  108. interface OnboardingStepProps extends ModalRenderProps {
  109. organization: Organization;
  110. project: Project | null;
  111. setProject: React.Dispatch<React.SetStateAction<Project | null>>;
  112. step: OnboardingStep;
  113. toStep: OnboardingRouterState[1];
  114. }
  115. function SelectProjectStep({
  116. Body: ModalBody,
  117. Header: ModalHeader,
  118. Footer: ModalFooter,
  119. closeModal,
  120. toStep,
  121. step,
  122. project,
  123. setProject,
  124. organization,
  125. }: OnboardingStepProps) {
  126. const {projects} = useProjects();
  127. const onFormSubmit = useCallback(
  128. (evt: React.FormEvent) => {
  129. evt.preventDefault();
  130. if (!project?.platform) {
  131. return;
  132. }
  133. const nextStep = platformToInstructionsMapping[project.platform];
  134. if (nextStep === undefined) {
  135. throw new TypeError(
  136. "Platform doesn't have a onboarding step, user should not be able to select it"
  137. );
  138. }
  139. toStep({
  140. previous: step,
  141. current: nextStep,
  142. next: null,
  143. });
  144. },
  145. [project, step, toStep]
  146. );
  147. const projectSelectOptions = useMemo((): SelectFieldProps<Project>['options'] => {
  148. const {supported: supportedProjects, unsupported: unsupporedProjects} =
  149. splitProjectsByProfilingSupport(projects);
  150. return [
  151. {
  152. label: t('Supported'),
  153. options: supportedProjects.map(p => asSelectOption(p, {disabled: false})),
  154. },
  155. {
  156. label: t('Unsupported'),
  157. options: unsupporedProjects.map(p => asSelectOption(p, {disabled: true})),
  158. },
  159. ];
  160. }, [projects]);
  161. const sdkUpdates = useProjectSdkUpdates({organization, projectId: project?.id ?? null});
  162. return (
  163. <ModalBody>
  164. <ModalHeader>
  165. <h3>{t('Set Up Profiling')}</h3>
  166. </ModalHeader>
  167. <form onSubmit={onFormSubmit}>
  168. <StyledList symbol="colored-numeric">
  169. <li>
  170. <StepTitle>
  171. <label htmlFor="project-select">{t('Select a project')}</label>
  172. </StepTitle>
  173. <div>
  174. <StyledSelectField
  175. id="project-select"
  176. name="select"
  177. options={projectSelectOptions}
  178. onChange={setProject}
  179. />
  180. </div>
  181. </li>
  182. {project?.platform === 'android' ? (
  183. <AndroidInstallSteps
  184. sdkUpdates={sdkUpdates}
  185. project={project}
  186. organization={organization}
  187. />
  188. ) : null}
  189. {project?.platform === 'apple-ios' ? (
  190. <IOSInstallSteps
  191. sdkUpdates={sdkUpdates}
  192. project={project}
  193. organization={organization}
  194. />
  195. ) : null}
  196. </StyledList>
  197. <ModalFooter>
  198. <ModalActions>
  199. <DocsLink />
  200. <div>
  201. <StepIndicator>{t('Step 1 of 2')}</StepIndicator>
  202. <PreviousStepButton type="button" onClick={closeModal} />
  203. <NextStepButton
  204. disabled={
  205. !(project?.platform && platformToInstructionsMapping[project.platform])
  206. }
  207. type="submit"
  208. />
  209. </div>
  210. </ModalActions>
  211. </ModalFooter>
  212. </form>
  213. </ModalBody>
  214. );
  215. }
  216. function SetupPerformanceMonitoringStep({href}: {href: string}) {
  217. return (
  218. <Fragment>
  219. <StepTitle>{t('Setup Performance Monitoring')}</StepTitle>
  220. {tct(
  221. `For Profiling to function, it's required to set up Performance monitoring. Follow the [setupDocs]`,
  222. {
  223. setupDocs: (
  224. <ExternalLink openInNewTab href={href}>
  225. {t('step-by-step instructions here.')}
  226. </ExternalLink>
  227. ),
  228. }
  229. )}
  230. </Fragment>
  231. );
  232. }
  233. interface ProjectSdkUpdateProps {
  234. minSdkVersion: string;
  235. organization: Organization;
  236. project: Project;
  237. sdkUpdates: ProjectSdkUpdates;
  238. }
  239. function ProjectSdkUpdate({
  240. sdkUpdates,
  241. organization,
  242. project,
  243. minSdkVersion,
  244. }: ProjectSdkUpdateProps) {
  245. const updateSdkSuggestion = sdkUpdates.suggestions.find(v => v.type === 'updateSdk') as
  246. | UpdateSdkSuggestion
  247. | undefined;
  248. return (
  249. <Fragment>
  250. <SDKUpdatesContainer>
  251. <SdkUpdatesPlatformIcon platform={project.platform ?? 'unknown'} />
  252. <Link
  253. to={`/organizations/${organization.slug}/projects/${project.slug}/?project=${project.id}`}
  254. >
  255. {project.name}
  256. </Link>
  257. </SDKUpdatesContainer>
  258. <SdkUpdatesText>
  259. {t('This project is on %s@%s', sdkUpdates.sdkName, sdkUpdates.sdkVersion)}
  260. <br />
  261. {updateSdkSuggestion ? (
  262. <Link to={updateSdkSuggestion.sdkUrl ?? ''}>
  263. {t(
  264. 'Update to %s@%s',
  265. updateSdkSuggestion.sdkName,
  266. updateSdkSuggestion.newSdkVersion
  267. )}
  268. </Link>
  269. ) : (
  270. t('Update to %s or higher', minSdkVersion)
  271. )}
  272. </SdkUpdatesText>
  273. </Fragment>
  274. );
  275. }
  276. const SdkUpdatesText = styled('p')`
  277. margin-top: ${space(0.75)};
  278. padding-left: ${space(4)};
  279. `;
  280. // doesnt use space because it's just off by 2px all the time
  281. const SdkUpdatesPlatformIcon = styled(PlatformIcon)`
  282. margin-right: 10px;
  283. `;
  284. const SDKUpdatesContainer = styled('div')`
  285. display: flex;
  286. align-items: center;
  287. margin-top: ${space(1.5)};
  288. font-size: ${p => p.theme.fontSizeLarge};
  289. `;
  290. interface InstallStepsProps {
  291. organization: Organization;
  292. project: Project;
  293. sdkUpdates: RequestState<ProjectSdkUpdates | null>;
  294. }
  295. function AndroidInstallSteps({project, sdkUpdates, organization}: InstallStepsProps) {
  296. const hasSdkUpdates = sdkUpdates.type === 'resolved' && sdkUpdates.data !== null;
  297. const requiresSdkUpdates =
  298. hasSdkUpdates && sdkUpdates.data?.sdkVersion
  299. ? semverCompare(sdkUpdates.data.sdkVersion, '6.0.0') < 0
  300. : false;
  301. return (
  302. <Fragment>
  303. {hasSdkUpdates && requiresSdkUpdates && (
  304. <li>
  305. <StepTitle>{t('Update your projects SDK version')}</StepTitle>
  306. <ProjectSdkUpdate
  307. minSdkVersion="6.0.0 (sentry-android)"
  308. project={project}
  309. sdkUpdates={sdkUpdates.data!}
  310. organization={organization}
  311. />
  312. </li>
  313. )}
  314. <li>
  315. <SetupPerformanceMonitoringStep href="https://docs.sentry.io/platforms/android/performance/" />
  316. </li>
  317. <li>
  318. <StepTitle>{t('Set Up Profiling')}</StepTitle>
  319. <CodeSnippet language="xml" filename="AndroidManifest.xml">
  320. {`<application>
  321. <meta-data android:name="io.sentry.dsn" android:value="..." />
  322. <meta-data android:name="io.sentry.traces.sample-rate" android:value="1.0" />
  323. <meta-data android:name="io.sentry.traces.profiling.enable" android:value="true" />
  324. </application>`}
  325. </CodeSnippet>
  326. </li>
  327. </Fragment>
  328. );
  329. }
  330. function IOSInstallSteps({project, sdkUpdates, organization}: InstallStepsProps) {
  331. const hasSdkUpdates = sdkUpdates.type === 'resolved' && sdkUpdates.data !== null;
  332. const requiresSdkUpdates =
  333. hasSdkUpdates && sdkUpdates.data?.sdkVersion
  334. ? semverCompare(sdkUpdates.data.sdkVersion, '7.23.0') < 0
  335. : false;
  336. return (
  337. <Fragment>
  338. {hasSdkUpdates && requiresSdkUpdates && (
  339. <li>
  340. <StepTitle>{t('Update your projects SDK version')}</StepTitle>
  341. <ProjectSdkUpdate
  342. minSdkVersion="7.23.0 (sentry-cocoa)"
  343. project={project}
  344. sdkUpdates={sdkUpdates.data!}
  345. organization={organization}
  346. />
  347. </li>
  348. )}
  349. <li>
  350. <SetupPerformanceMonitoringStep href="https://docs.sentry.io/platforms/apple/guides/ios/performance/" />
  351. </li>
  352. <li>
  353. <StepTitle>
  354. {t('Enable profiling in your app by configuring the SDKs like below:')}
  355. </StepTitle>
  356. <CodeSnippet language="swift">{`SentrySDK.start { options in
  357. options.dsn = "..."
  358. options.tracesSampleRate = 1.0 // Make sure transactions are enabled
  359. options.profilesSampleRate = 1.0
  360. }`}</CodeSnippet>
  361. </li>
  362. </Fragment>
  363. );
  364. }
  365. const StyledList = styled(List)`
  366. position: relative;
  367. li {
  368. margin-bottom: ${space(3)};
  369. }
  370. `;
  371. const StyledSelectField = styled(SelectField)`
  372. padding: 0;
  373. border-bottom: 0;
  374. > div {
  375. width: 100%;
  376. padding-left: 0;
  377. }
  378. `;
  379. function AndroidSendDebugFilesInstruction({
  380. Body: ModalBody,
  381. Header: ModalHeader,
  382. Footer: ModalFooter,
  383. closeModal,
  384. toStep,
  385. step,
  386. }: OnboardingStepProps) {
  387. return (
  388. <ModalBody>
  389. <ModalHeader>
  390. <h3>{t('Set Up Profiling')}</h3>
  391. </ModalHeader>
  392. <p>
  393. {t(
  394. `If you want to see de-obfuscated stack traces, you'll need to use ProGuard with Sentry. To do so, upload the ProGuard mapping files by either the recommended method of using our Gradle integration or manually by using sentry-cli.`
  395. )}{' '}
  396. <ExternalLink href="https://docs.sentry.io/product/cli/dif/">
  397. {t('Learn more about Debug Information Files.')}
  398. </ExternalLink>
  399. </p>
  400. <OptionsContainer>
  401. <OptionTitleContainer>
  402. <OptionTitle>{t('Option 1')}</OptionTitle> <Tag>{t('Recommended')}</Tag>
  403. </OptionTitleContainer>
  404. <OptionTitleContainer>
  405. <OptionTitle>{t('Option 2')}</OptionTitle>
  406. </OptionTitleContainer>
  407. </OptionsContainer>
  408. <OptionsContainer>
  409. <Option>
  410. <ExternalOptionTitle href="https://docs.sentry.io/platforms/android/proguard/">
  411. {t('Proguard and DexGuard')}
  412. <IconOpen />
  413. </ExternalOptionTitle>
  414. <p>{t('Upload ProGuard files using our Gradle plugin.')}</p>
  415. </Option>
  416. <Option>
  417. <ExternalOptionTitle href="https://docs.sentry.io/product/cli/dif/#uploading-files">
  418. {t('Sentry-cli')}
  419. <IconOpen />
  420. </ExternalOptionTitle>
  421. <p>{t('Validate and upload debug files using our cli tool.')}</p>
  422. </Option>
  423. </OptionsContainer>
  424. <ModalFooter>
  425. <ModalActions>
  426. <DocsLink />
  427. <div>
  428. <StepIndicator>{t('Step 2 of 2')}</StepIndicator>
  429. {step.previous ? (
  430. <PreviousStepButton onClick={() => toStep(step.previous)} />
  431. ) : null}
  432. <Button priority="primary" onClick={closeModal}>
  433. {t('Done')}
  434. </Button>
  435. </div>
  436. </ModalActions>
  437. </ModalFooter>
  438. </ModalBody>
  439. );
  440. }
  441. function IOSSendDebugFilesInstruction({
  442. Body: ModalBody,
  443. Header: ModalHeader,
  444. Footer: ModalFooter,
  445. closeModal,
  446. toStep,
  447. step,
  448. }: OnboardingStepProps) {
  449. return (
  450. <ModalBody>
  451. <ModalHeader>
  452. <h3>{t('Set Up Profiling')}</h3>
  453. </ModalHeader>
  454. <p>
  455. {t(`The most straightforward way to provide Sentry with debug information files is to
  456. upload them using sentry-cli. Depending on your workflow, you may want to upload
  457. as part of your build pipeline or when deploying and publishing your application.`)}{' '}
  458. <ExternalLink href="https://docs.sentry.io/product/cli/dif/">
  459. {t('Learn more about Debug Information Files.')}
  460. </ExternalLink>
  461. </p>
  462. <OptionsContainer>
  463. <OptionTitleContainer>
  464. <OptionTitle>{t('Option 1')}</OptionTitle> <Tag>{t('Recommended')}</Tag>
  465. </OptionTitleContainer>
  466. <OptionTitleContainer>
  467. <OptionTitle>{t('Option 2')}</OptionTitle>
  468. </OptionTitleContainer>
  469. </OptionsContainer>
  470. <OptionsContainer>
  471. <Option>
  472. <ExternalOptionTitle href="https://docs.sentry.io/product/cli/dif/#uploading-files">
  473. {t('Sentry-cli')}
  474. <IconOpen />
  475. </ExternalOptionTitle>
  476. <p>{t('Validate and upload debug files using our cli tool.')}</p>
  477. </Option>
  478. <Option>
  479. <ExternalOptionTitle href="https://docs.sentry.io/platforms/apple/dsym/">
  480. {t('Symbol servers')}
  481. <IconOpen />
  482. </ExternalOptionTitle>
  483. <p>
  484. {t('Sentry downloads debug information files from external repositories.')}
  485. </p>
  486. </Option>
  487. </OptionsContainer>
  488. <ModalFooter>
  489. <ModalActions>
  490. <DocsLink />
  491. <div>
  492. <StepIndicator>{t('Step 2 of 2')}</StepIndicator>
  493. {step.previous !== null ? (
  494. <PreviousStepButton onClick={() => toStep(step.previous)} />
  495. ) : null}
  496. <Button priority="primary" onClick={closeModal}>
  497. {t('Next')}
  498. </Button>
  499. </div>
  500. </ModalActions>
  501. </ModalFooter>
  502. </ModalBody>
  503. );
  504. }
  505. type StepButtonProps = Omit<ButtonPropsWithoutAriaLabel, 'children'>;
  506. // A few common component definitions that are used in each step
  507. function NextStepButton(props: StepButtonProps) {
  508. return (
  509. <Button priority="primary" {...props}>
  510. {t('Next')}
  511. </Button>
  512. );
  513. }
  514. function PreviousStepButton(props: StepButtonProps) {
  515. return <Button {...props}>{t('Back')}</Button>;
  516. }
  517. function DocsLink() {
  518. return (
  519. <Button external href="https://docs.sentry.io/product/profiling/">
  520. {t('Read Docs')}
  521. </Button>
  522. );
  523. }
  524. interface ModalActionsProps {
  525. children: React.ReactNode;
  526. }
  527. function ModalActions({children}: ModalActionsProps) {
  528. return <ModalActionsContainer>{children}</ModalActionsContainer>;
  529. }
  530. const OptionTitleContainer = styled('div')`
  531. margin-bottom: ${space(0.5)};
  532. `;
  533. const OptionTitle = styled('span')`
  534. font-weight: bold;
  535. `;
  536. const ExternalOptionTitle = styled(ExternalLink)`
  537. font-weight: bold;
  538. font-size: ${p => p.theme.fontSizeLarge};
  539. display: flex;
  540. align-items: center;
  541. margin-bottom: ${space(0.5)};
  542. svg {
  543. margin-left: ${space(0.5)};
  544. }
  545. `;
  546. const Option = styled('div')`
  547. border-radius: ${p => p.theme.borderRadius};
  548. border: 1px solid ${p => p.theme.border};
  549. padding: ${space(2)};
  550. margin-top: ${space(1)};
  551. `;
  552. const OptionsContainer = styled('div')`
  553. display: grid;
  554. grid-template-columns: 1fr 1fr;
  555. gap: ${space(2)};
  556. > p {
  557. margin: 0;
  558. }
  559. `;
  560. const ModalActionsContainer = styled('div')`
  561. display: flex;
  562. justify-content: space-between;
  563. align-items: center;
  564. flex: 1 1 100%;
  565. button:not(:last-child) {
  566. margin-right: ${space(1)};
  567. }
  568. `;
  569. const StepTitle = styled('div')`
  570. margin-bottom: ${space(1)};
  571. font-weight: bold;
  572. `;
  573. const StepIndicator = styled('span')`
  574. color: ${p => p.theme.subText};
  575. margin-right: ${space(2)};
  576. `;