addDataVolume.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import {FeatureBadge} from 'sentry/components/core/badge/featureBadge';
  5. import RangeSlider from 'sentry/components/forms/controls/rangeSlider';
  6. import {Body, Header, Hovercard} from 'sentry/components/hovercard';
  7. import ExternalLink from 'sentry/components/links/externalLink';
  8. import Panel from 'sentry/components/panels/panel';
  9. import PanelBody from 'sentry/components/panels/panelBody';
  10. import PanelFooter from 'sentry/components/panels/panelFooter';
  11. import PanelItem from 'sentry/components/panels/panelItem';
  12. import QuestionTooltip from 'sentry/components/questionTooltip';
  13. import {IconLightning, IconQuestion} from 'sentry/icons';
  14. import {t, tct} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import {DataCategory} from 'sentry/types/core';
  17. import {PlanTier} from 'getsentry/types';
  18. import {formatReservedWithUnits} from 'getsentry/utils/billing';
  19. import {getPlanCategoryName} from 'getsentry/utils/dataCategory';
  20. import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics';
  21. import StepHeader from 'getsentry/views/amCheckout/steps/stepHeader';
  22. import UnitTypeItem from 'getsentry/views/amCheckout/steps/unitTypeItem';
  23. import {getDataCategoryTooltipText} from 'getsentry/views/amCheckout/steps/utils';
  24. import type {StepProps} from 'getsentry/views/amCheckout/types';
  25. import * as utils from 'getsentry/views/amCheckout/utils';
  26. const ATTACHMENT_DIGITS = 2;
  27. type Props = StepProps;
  28. class AddDataVolume extends Component<Props> {
  29. componentDidUpdate(prevProps: Props) {
  30. const {isActive, organization} = this.props;
  31. // record when step is opened
  32. if (prevProps.isActive || !isActive) {
  33. return;
  34. }
  35. if (organization) {
  36. trackGetsentryAnalytics('checkout.data_sliders_viewed', {
  37. organization,
  38. });
  39. }
  40. }
  41. get title() {
  42. return t('Reserved Volumes');
  43. }
  44. handleChange(value: number, category: string) {
  45. const {organization, onUpdate, formData} = this.props;
  46. onUpdate({reserved: {...formData.reserved, [category]: value}});
  47. if (organization) {
  48. trackGetsentryAnalytics('checkout.data_slider_changed', {
  49. organization,
  50. data_type: category,
  51. quantity: value,
  52. });
  53. }
  54. }
  55. renderLearnMore() {
  56. return (
  57. <LearnMore>
  58. <FeatureBadge type="new" />
  59. <span>
  60. {tct(
  61. 'Sentry will dynamically sample transaction volume at scale. [learnMore]',
  62. {
  63. learnMore: (
  64. <ExternalLink href="https://docs.sentry.io/product/data-management-settings/dynamic-sampling/">
  65. {t('Learn more.')}
  66. </ExternalLink>
  67. ),
  68. }
  69. )}
  70. </span>
  71. </LearnMore>
  72. );
  73. }
  74. renderPerformanceUnits() {
  75. return (
  76. <PerformanceUnits>
  77. <PerformanceTag>
  78. <IconLightning size="sm" />
  79. {t('Sentry Performance')}
  80. </PerformanceTag>
  81. {t('Total Units')}
  82. </PerformanceUnits>
  83. );
  84. }
  85. renderHovercardBody() {
  86. return (
  87. <Fragment>
  88. <UnitTypeItem
  89. unitName={t('Transactions')}
  90. description={t(
  91. 'Transactions are sent when your service receives a request and sends a response.'
  92. )}
  93. weight="1.0"
  94. />
  95. <UnitTypeItem
  96. unitName={t('Transactions with Profiling')}
  97. description={t(
  98. 'Transactions with Profiling provide the deepest level of visibility for your apps.'
  99. )}
  100. weight="1.3"
  101. />
  102. </Fragment>
  103. );
  104. }
  105. renderPerformanceHovercard() {
  106. return (
  107. <StyledHovercard
  108. position="top"
  109. header={<div>{t('Performance Event Types')}</div>}
  110. body={this.renderHovercardBody()}
  111. >
  112. <IconContainer>
  113. <IconQuestion size="xs" color="subText" />
  114. </IconContainer>
  115. </StyledHovercard>
  116. );
  117. }
  118. renderBody = () => {
  119. const {organization, subscription, formData, activePlan, checkoutTier} = this.props;
  120. return (
  121. <PanelBody data-test-id={this.title}>
  122. {activePlan.checkoutCategories.map(category => {
  123. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  124. const allowedValues = activePlan.planCategories[category as DataCategory]!.map(
  125. (bucket: any) => bucket.events
  126. );
  127. const eventBucket = utils.getBucket({
  128. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  129. events: formData.reserved[category],
  130. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  131. buckets: activePlan.planCategories[category],
  132. });
  133. const min = allowedValues[0];
  134. const max = allowedValues.slice(-1)[0];
  135. const isMonitorCategory =
  136. category === DataCategory.MONITOR_SEATS || category === DataCategory.UPTIME;
  137. const billingInterval = utils.getShortInterval(activePlan.billingInterval);
  138. const price = utils.displayPrice({cents: eventBucket.price});
  139. const unitPrice = utils.displayUnitPrice({
  140. cents: eventBucket.unitPrice || 0,
  141. ...(category === DataCategory.ATTACHMENTS
  142. ? {
  143. minDigits: ATTACHMENT_DIGITS,
  144. maxDigits: ATTACHMENT_DIGITS,
  145. }
  146. : {}),
  147. });
  148. const showPerformanceUnits =
  149. checkoutTier === PlanTier.AM2 &&
  150. organization?.features?.includes('profiling-billing') &&
  151. category === DataCategory.TRANSACTIONS;
  152. // TODO: Remove after profiling launch
  153. const showTransactionsDisclaimer =
  154. !showPerformanceUnits &&
  155. category === DataCategory.TRANSACTIONS &&
  156. checkoutTier === PlanTier.AM2 &&
  157. subscription.planTier === PlanTier.AM1 &&
  158. subscription.planDetails.name === activePlan.name &&
  159. subscription.billingInterval === activePlan.billingInterval &&
  160. (subscription.categories.transactions?.reserved ?? 0) > 5_000_000;
  161. const sliderId = `slider-${category}`;
  162. return (
  163. <DataVolumeItem key={category} data-test-id={`${category}-volume-item`}>
  164. <div>
  165. {showPerformanceUnits && this.renderPerformanceUnits()}
  166. <SectionHeader>
  167. <Title htmlFor={sliderId}>
  168. <div>{getPlanCategoryName({plan: activePlan, category})}</div>
  169. {showPerformanceUnits ? (
  170. this.renderPerformanceHovercard()
  171. ) : (
  172. <QuestionTooltip
  173. title={getDataCategoryTooltipText(checkoutTier, category)}
  174. position="top"
  175. size="xs"
  176. />
  177. )}
  178. </Title>
  179. <Events>
  180. {
  181. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  182. formatReservedWithUnits(formData.reserved[category], category)
  183. }
  184. </Events>
  185. </SectionHeader>
  186. <Description>
  187. <div>
  188. {eventBucket.price !== 0 &&
  189. // Monitors are an exception and do not render a price.
  190. //
  191. // NOTE(davidenwang): If we decide to change the reserved price of a monitor
  192. // to zero we would no longer need this check, since the above check would
  193. // handle this
  194. !isMonitorCategory &&
  195. tct('[unitPrice] per [category]', {
  196. category:
  197. category === DataCategory.ATTACHMENTS
  198. ? 'GB'
  199. : showPerformanceUnits
  200. ? 'unit'
  201. : 'event',
  202. unitPrice,
  203. })}
  204. </div>
  205. <div>
  206. {eventBucket.price === 0
  207. ? t('included')
  208. : `${price}/${billingInterval}`}
  209. </div>
  210. </Description>
  211. </div>
  212. {!isMonitorCategory && (
  213. <div>
  214. <RangeSlider
  215. showLabel={false}
  216. name={category}
  217. id={sliderId}
  218. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  219. value={formData.reserved[category]}
  220. allowedValues={allowedValues}
  221. formatLabel={() => null}
  222. onChange={value => value && this.handleChange(value, category)}
  223. />
  224. <MinMax>
  225. <div>{utils.getEventsWithUnit(min, category)}</div>
  226. <div>{utils.getEventsWithUnit(max, category)}</div>
  227. </MinMax>
  228. </div>
  229. )}
  230. {showTransactionsDisclaimer && (
  231. <span>
  232. {t(
  233. 'We updated your event quota to make sure you get the best cost per transaction. Feel free to adjust as needed.'
  234. )}
  235. </span>
  236. )}
  237. {/* TODO: Remove after profiling launch */}
  238. {!showPerformanceUnits &&
  239. category === DataCategory.TRANSACTIONS &&
  240. activePlan.features.includes('dynamic-sampling') &&
  241. this.renderLearnMore()}
  242. </DataVolumeItem>
  243. );
  244. })}
  245. </PanelBody>
  246. );
  247. };
  248. renderFooter = () => {
  249. const {stepNumber, onCompleteStep} = this.props;
  250. return (
  251. <StepFooter data-test-id={this.title}>
  252. <div>
  253. {tct('Need more data? Add On-Demand Budget, or [link:Contact Sales]', {
  254. link: <a href="mailto:sales@sentry.io" />,
  255. })}
  256. </div>
  257. <Button priority="primary" onClick={() => onCompleteStep(stepNumber)}>
  258. {t('Continue')}
  259. </Button>
  260. </StepFooter>
  261. );
  262. };
  263. render() {
  264. const {isActive, stepNumber, isCompleted, onEdit} = this.props;
  265. return (
  266. <Panel data-test-id="step-add-data-volume">
  267. <StepHeader
  268. canSkip
  269. title={this.title}
  270. isActive={isActive}
  271. stepNumber={stepNumber}
  272. isCompleted={isCompleted}
  273. onEdit={onEdit}
  274. />
  275. {isActive && this.renderBody()}
  276. {isActive && this.renderFooter()}
  277. </Panel>
  278. );
  279. }
  280. }
  281. export default AddDataVolume;
  282. const LearnMore = styled('div')`
  283. display: grid;
  284. grid-template-columns: max-content auto;
  285. gap: ${space(1)};
  286. padding: ${space(1)};
  287. background: ${p => p.theme.backgroundSecondary};
  288. color: ${p => p.theme.subText};
  289. align-items: center;
  290. `;
  291. const StyledHovercard = styled(Hovercard)`
  292. width: 400px;
  293. ${Header} {
  294. color: ${p => p.theme.gray300};
  295. text-transform: uppercase;
  296. font-size: ${p => p.theme.fontSizeSmall};
  297. border-radius: 6px 6px 0px 0px;
  298. padding: ${space(2)};
  299. }
  300. ${Body} {
  301. padding: 0px;
  302. }
  303. `;
  304. const IconContainer = styled('span')`
  305. svg {
  306. transition: 120ms opacity;
  307. opacity: 0.6;
  308. &:hover {
  309. opacity: 1;
  310. }
  311. }
  312. `;
  313. const BaseRow = styled('div')`
  314. display: grid;
  315. grid-auto-flow: column;
  316. justify-content: space-between;
  317. align-items: center;
  318. `;
  319. const PerformanceUnits = styled(BaseRow)`
  320. text-transform: uppercase;
  321. font-size: ${p => p.theme.fontSizeSmall};
  322. font-weight: 600;
  323. `;
  324. const PerformanceTag = styled(BaseRow)`
  325. gap: ${space(0.5)};
  326. color: ${p => p.theme.purple300};
  327. `;
  328. // body
  329. const DataVolumeItem = styled(PanelItem)`
  330. display: grid;
  331. grid-auto-flow: row;
  332. gap: ${space(3)};
  333. font-weight: normal;
  334. width: 100%;
  335. margin: 0;
  336. border-bottom: 1px solid ${p => p.theme.innerBorder};
  337. `;
  338. const SectionHeader = styled('div')`
  339. display: grid;
  340. grid-template-columns: repeat(2, auto);
  341. justify-content: space-between;
  342. color: ${p => p.theme.textColor};
  343. font-size: ${p => p.theme.fontSizeExtraLarge};
  344. `;
  345. const Title = styled('label')`
  346. display: grid;
  347. grid-auto-flow: column;
  348. gap: ${space(0.5)};
  349. align-items: center;
  350. margin-bottom: 0px;
  351. font-weight: 600;
  352. `;
  353. const Description = styled('div')`
  354. display: grid;
  355. grid-template-columns: repeat(2, auto);
  356. justify-content: space-between;
  357. font-size: ${p => p.theme.fontSizeMedium};
  358. color: ${p => p.theme.gray300};
  359. `;
  360. const Events = styled('div')`
  361. font-size: ${p => p.theme.headerFontSize};
  362. margin: 0;
  363. `;
  364. const MinMax = styled(Description)`
  365. font-size: ${p => p.theme.fontSizeSmall};
  366. `;
  367. // footer
  368. const StepFooter = styled(PanelFooter)`
  369. padding: ${space(2)};
  370. display: grid;
  371. grid-template-columns: auto max-content;
  372. gap: ${space(1)};
  373. align-items: center;
  374. `;