pendo.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import pick from 'lodash/pick';
  2. import {DataCategory} from 'sentry/types/core';
  3. import type {Organization} from 'sentry/types/organization';
  4. import getDaysSinceDate from 'sentry/utils/getDaysSinceDate';
  5. import getOrganizationAge from 'sentry/utils/getOrganizationAge';
  6. import type {PromotionClaimed, Subscription} from 'getsentry/types';
  7. import {getProductTrial, getTrialDaysLeft} from './billing';
  8. // we encode sizes for bucketing using roygbiv coloring
  9. const SIZES = {
  10. NONE: 'red',
  11. XXSMALL: 'orange',
  12. XSMALL: 'yellow',
  13. SMALL: 'green',
  14. MEDIUM: 'blue',
  15. LARGE: 'indigo',
  16. XLARGE: 'violet',
  17. XXLARGE: 'teal',
  18. XXXLARGE: 'magenta',
  19. } as const;
  20. // a map of custom fields that rely on bucketing
  21. const CUSTOM_BUCKET_FIELDS = {
  22. arr: getReservedTotalFromSubscription,
  23. accountCredit: getAccountCredit,
  24. };
  25. type CustomFields = keyof typeof CUSTOM_BUCKET_FIELDS;
  26. type Size = (typeof SIZES)[keyof typeof SIZES];
  27. type BucketRecord = Array<[number, Size]>;
  28. type BucketMap = Partial<Record<keyof Subscription | CustomFields, BucketRecord>>;
  29. // the values in the bucket are the max values for each bucket inclusive
  30. // soo arr = 1,000 is small and 1,001 is medium
  31. const BUCKET_MAP: BucketMap = {
  32. totalMembers: [
  33. [1, SIZES.XXSMALL],
  34. [5, SIZES.XSMALL],
  35. [25, SIZES.SMALL],
  36. [100, SIZES.MEDIUM],
  37. [250, SIZES.LARGE],
  38. [500, SIZES.XLARGE],
  39. [1000, SIZES.XXLARGE],
  40. ],
  41. reservedErrors: [
  42. [5_000, SIZES.XXSMALL],
  43. [50_000, SIZES.XSMALL],
  44. [200_000, SIZES.SMALL],
  45. [1_000_000, SIZES.MEDIUM],
  46. [5_000_000, SIZES.LARGE],
  47. [10_000_000, SIZES.XLARGE],
  48. [15_000_000, SIZES.XXLARGE],
  49. ],
  50. reservedTransactions: [
  51. [10_000, SIZES.XXSMALL],
  52. [100_000, SIZES.XSMALL],
  53. [400_000, SIZES.SMALL],
  54. [2_000_000, SIZES.MEDIUM],
  55. [10_000_000, SIZES.LARGE],
  56. [20_000_000, SIZES.XLARGE],
  57. [50_000_000, SIZES.XXLARGE],
  58. ],
  59. arr: [
  60. [500, SIZES.XXSMALL],
  61. [1_000, SIZES.XSMALL],
  62. [5_000, SIZES.SMALL],
  63. [20_000, SIZES.MEDIUM],
  64. [50_000, SIZES.LARGE],
  65. [100_000_000, SIZES.XLARGE],
  66. [200_000_000, SIZES.XXLARGE],
  67. ],
  68. // $0 is red, <$5 is orange, $5-$29 is yellow, $29-$89 is green, $89-$348 is blue, $348-$1068 is indigo, $1068+ is violet
  69. accountCredit: [
  70. [4.99, SIZES.XXSMALL],
  71. [28.99, SIZES.XSMALL], // $29 (1 month team plan)
  72. [88.99, SIZES.SMALL], // $89 (1 month business plan)
  73. [347.99, SIZES.MEDIUM], // $348 (1 year team plan)
  74. [1067.99, SIZES.LARGE], // $1068 (1 year business plan)
  75. [2_000, SIZES.XLARGE],
  76. [4_000, SIZES.XXLARGE],
  77. ],
  78. };
  79. export function getPendoAccountFields(
  80. subscription: Subscription,
  81. organization: Organization,
  82. {
  83. activePromotions,
  84. completedPromotions,
  85. }: {
  86. activePromotions: PromotionClaimed[] | null;
  87. completedPromotions: PromotionClaimed[] | null;
  88. }
  89. ) {
  90. // add basic fields as-is
  91. const baseAccountFields = {
  92. ...pick(subscription, [
  93. 'isFree',
  94. 'isManaged',
  95. 'isTrial',
  96. 'isEnterpriseTrial',
  97. 'isPerformancePlanTrial',
  98. 'isSuspended',
  99. 'canTrial',
  100. 'canSelfServe',
  101. 'plan',
  102. 'planTier',
  103. ]),
  104. ...pick(organization, ['isEarlyAdopter']),
  105. };
  106. // for fields with bucketing, we need to encode the value so
  107. // we can obfuscate this information
  108. for (const field in BUCKET_MAP) {
  109. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  110. const buckets = BUCKET_MAP[field];
  111. let value: number | undefined;
  112. if (field in subscription) {
  113. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  114. value = subscription[field];
  115. } else if (field in CUSTOM_BUCKET_FIELDS) {
  116. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  117. value = CUSTOM_BUCKET_FIELDS[field](subscription);
  118. }
  119. if (value !== undefined) {
  120. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  121. baseAccountFields[field] = getBucketValue(value, buckets);
  122. }
  123. }
  124. const promoInfo: {
  125. activePromotion: null | string;
  126. completedPromotions: string;
  127. daysSincePromotionClaimed: number;
  128. freeEventCreditDaysLeft: number;
  129. isLastCycleForFreeEvents: boolean;
  130. promotionDaysLeft: number;
  131. } = {
  132. activePromotion: null,
  133. promotionDaysLeft: -1,
  134. completedPromotions: (completedPromotions || []).map(p => p.promotion.slug).join(','),
  135. freeEventCreditDaysLeft: -1,
  136. isLastCycleForFreeEvents: false,
  137. daysSincePromotionClaimed: -1,
  138. };
  139. if (activePromotions && activePromotions.length > 0) {
  140. const promo = activePromotions[0]!;
  141. promoInfo.activePromotion = promo.promotion.slug;
  142. promoInfo.daysSincePromotionClaimed = getDaysSinceDate(promo.dateClaimed);
  143. if (promo.promotion.endDate) {
  144. promoInfo.promotionDaysLeft = -1 * getDaysSinceDate(promo.promotion.endDate);
  145. }
  146. }
  147. if (completedPromotions && completedPromotions.length > 0) {
  148. const promo = completedPromotions[0]!;
  149. promoInfo.freeEventCreditDaysLeft = promo.freeEventCreditDaysLeft;
  150. promoInfo.isLastCycleForFreeEvents = promo.isLastCycleForFreeEvents;
  151. promoInfo.daysSincePromotionClaimed = getDaysSinceDate(promo.dateClaimed);
  152. }
  153. const perfTrial = getProductTrial(
  154. subscription.productTrials ?? null,
  155. DataCategory.TRANSACTIONS
  156. );
  157. const perfTrialAvailable: boolean = perfTrial ? !perfTrial.isStarted : false;
  158. const perfTrialStartDate: string = perfTrial?.startDate ?? '';
  159. const perfTrialEndDate: string = perfTrial?.endDate ?? '';
  160. const perfTrialActive: boolean = perfTrial
  161. ? perfTrial.isStarted && getDaysSinceDate(perfTrial.endDate ?? '') <= 0
  162. : false;
  163. const replayTrial = getProductTrial(
  164. subscription.productTrials ?? null,
  165. DataCategory.REPLAYS
  166. );
  167. const replayTrialAvailable: boolean = replayTrial ? !replayTrial.isStarted : false;
  168. const replayTrialStartDate: string = replayTrial?.startDate ?? '';
  169. const replayTrialEndDate: string = replayTrial?.endDate ?? '';
  170. const replayTrialActive: boolean = replayTrial
  171. ? replayTrial.isStarted && getDaysSinceDate(replayTrial.endDate ?? '') <= 0
  172. : false;
  173. const profilesTrial = getProductTrial(
  174. subscription.productTrials ?? null,
  175. DataCategory.PROFILE_DURATION
  176. );
  177. const profilesTrialAvailable: boolean = profilesTrial
  178. ? !profilesTrial.isStarted
  179. : false;
  180. const profilesTrialStartDate: string = profilesTrial?.startDate ?? '';
  181. const profilesTrialEndDate: string = profilesTrial?.endDate ?? '';
  182. const profilesTrialActive: boolean = profilesTrial
  183. ? profilesTrial.isStarted && getDaysSinceDate(profilesTrial.endDate ?? '') <= 0
  184. : false;
  185. const spansTrial = getProductTrial(
  186. subscription.productTrials ?? null,
  187. DataCategory.SPANS
  188. );
  189. const spansTrialAvailable: boolean = spansTrial ? !spansTrial.isStarted : false;
  190. const spansTrialStartDate: string = spansTrial?.startDate ?? '';
  191. const spansTrialEndDate: string = spansTrial?.endDate ?? '';
  192. const spansTrialActive: boolean = spansTrial
  193. ? spansTrial.isStarted && getDaysSinceDate(spansTrial.endDate ?? '') <= 0
  194. : false;
  195. return {
  196. ...baseAccountFields,
  197. trialDaysLeft: getTrialDaysLeftFromSub(subscription),
  198. organizationAge: getOrganizationAge(organization),
  199. // don't want to send in actual on-demand spend
  200. // so just send in a boolean flag if it's enabled
  201. // may want to convert to a bucketed field eventually
  202. hasOnDemandSpend: subscription.onDemandMaxSpend > 0,
  203. considerForDsUpsell: getConsiderForDsUpsell(subscription),
  204. perfTrialAvailable,
  205. perfTrialStartDate,
  206. perfTrialEndDate,
  207. perfTrialActive,
  208. replayTrialAvailable,
  209. replayTrialStartDate,
  210. replayTrialEndDate,
  211. replayTrialActive,
  212. profilesTrialAvailable,
  213. profilesTrialStartDate,
  214. profilesTrialEndDate,
  215. profilesTrialActive,
  216. spansTrialAvailable,
  217. spansTrialStartDate,
  218. spansTrialEndDate,
  219. spansTrialActive,
  220. ...promoInfo,
  221. };
  222. }
  223. function getBucketValue(value: number, buckets: BucketRecord): Size {
  224. // if 0, use special case
  225. if (value === 0) {
  226. return SIZES.NONE;
  227. }
  228. for (const bucket of buckets) {
  229. // if our value is less than or equal to the bucket, use it
  230. if (value <= bucket[0]) {
  231. return bucket[1];
  232. }
  233. }
  234. // if no match, use xxxlarge
  235. return SIZES.XXXLARGE;
  236. }
  237. /**
  238. * Get the value in dollars of the current subscription as the input and divide by 100
  239. */
  240. function getReservedTotalFromSubscription(subscription: Subscription) {
  241. const {planDetails} = subscription;
  242. const monthlyPrice =
  243. planDetails.billingInterval === 'annual'
  244. ? planDetails.totalPrice / 12
  245. : planDetails.totalPrice;
  246. return monthlyPrice / 100.0;
  247. }
  248. function getAccountCredit(subscription: Subscription) {
  249. // invert to get credit and divide by 100 to get $ amount
  250. return subscription.accountBalance / -100.0;
  251. }
  252. function getTrialDaysLeftFromSub(subscription: Subscription) {
  253. // only check if trial is active
  254. if (!subscription.isTrial) {
  255. return null;
  256. }
  257. return getTrialDaysLeft(subscription);
  258. }
  259. // consider the org if they have at least 5m reserved transactions
  260. function getConsiderForDsUpsell(subscription: Subscription) {
  261. return (subscription.reservedTransactions || 0) >= 5_000_000;
  262. }