SplitTestManager.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. import { SplitTest } from '../../models/SplitTest.mjs'
  2. import Errors from '../Errors/Errors.js'
  3. import SplitTestUtils from './SplitTestUtils.mjs'
  4. import OError from '@overleaf/o-error'
  5. import _ from 'lodash'
  6. import { CacheFlow } from 'cache-flow'
  7. const ALPHA_PHASE = 'alpha'
  8. const LABS_PHASE = 'labs'
  9. const BETA_PHASE = 'beta'
  10. const RELEASE_PHASE = 'release'
  11. async function getSplitTests({ name, phase, type, active, archived }) {
  12. const filters = {}
  13. if (name && name !== '') {
  14. filters.name = { $regex: _.escapeRegExp(name) }
  15. }
  16. if (active) {
  17. filters.$where = 'this.versions[this.versions.length - 1].active === true'
  18. }
  19. if (type === 'split-test') {
  20. const query =
  21. 'this.versions[this.versions.length - 1].analyticsEnabled === true'
  22. if (filters.$where) {
  23. filters.$where += `&& ${query}`
  24. } else {
  25. filters.$where = query
  26. }
  27. }
  28. if (type === 'gradual-rollout') {
  29. const query =
  30. 'this.versions[this.versions.length - 1].analyticsEnabled === false'
  31. if (filters.$where) {
  32. filters.$where += `&& ${query}`
  33. } else {
  34. filters.$where = query
  35. }
  36. }
  37. if (['alpha', 'labs', 'beta', 'release'].includes(phase)) {
  38. const query = `this.versions[this.versions.length - 1].phase === "${phase}"`
  39. if (filters.$where) {
  40. filters.$where += `&& ${query}`
  41. } else {
  42. filters.$where = query
  43. }
  44. }
  45. if (archived === true) {
  46. filters.archived = true
  47. } else if (archived === false) {
  48. filters.archived = { $ne: true }
  49. }
  50. try {
  51. return await SplitTest.find(filters)
  52. .populate('archivedBy', ['email', 'first_name', 'last_name'])
  53. .populate('versions.author', ['email', 'first_name', 'last_name'])
  54. .limit(300)
  55. .exec()
  56. } catch (error) {
  57. throw OError.tag(error, 'Failed to get split tests list')
  58. }
  59. }
  60. async function getRuntimeTests() {
  61. try {
  62. return SplitTest.find({}).lean().exec()
  63. } catch (error) {
  64. throw OError.tag(error, 'Failed to get active split tests list')
  65. }
  66. }
  67. async function getSplitTest(query) {
  68. try {
  69. return await SplitTest.findOne(query)
  70. .populate('archivedBy', ['email', 'first_name', 'last_name'])
  71. .populate('versions.author', ['email', 'first_name', 'last_name'])
  72. .exec()
  73. } catch (error) {
  74. throw OError.tag(error, 'Failed to get split test', { query })
  75. }
  76. }
  77. async function createSplitTest(
  78. { name, configuration, badgeInfo = {}, info = {}, labsInfo = {} },
  79. userId
  80. ) {
  81. // Labs phase requires exactly one variant
  82. if (configuration.phase === LABS_PHASE) {
  83. if (configuration.variants.length !== 1) {
  84. throw new Errors.InvalidError('Labs phase requires exactly one variant')
  85. }
  86. // Auto-set 100% rollout for labs
  87. configuration.variants[0].rolloutPercent = 100
  88. }
  89. const stripedVariants = []
  90. let stripeStart = 0
  91. _checkNewVariantsConfiguration(
  92. [],
  93. configuration.variants,
  94. configuration.analyticsEnabled
  95. )
  96. for (const variant of configuration.variants) {
  97. const variantData = {
  98. name: (variant.name || '').trim(),
  99. rolloutPercent: variant.rolloutPercent,
  100. userLimit: variant.userLimit,
  101. rolloutStripes:
  102. variant.rolloutPercent > 0
  103. ? [
  104. {
  105. start: stripeStart,
  106. end: stripeStart + variant.rolloutPercent,
  107. },
  108. ]
  109. : [],
  110. }
  111. if (variant.userLimit && typeof variant.userLimit === 'number') {
  112. variantData.userCount = 0
  113. }
  114. stripedVariants.push(variantData)
  115. stripeStart += variant.rolloutPercent
  116. }
  117. const splitTest = new SplitTest({
  118. name: (name || '').trim(),
  119. description: info.description,
  120. ticketUrl: info.ticketUrl,
  121. reportsUrls: info.reportsUrls,
  122. winningVariant: info.winningVariant,
  123. badgeInfo,
  124. labsTitle: labsInfo.title,
  125. labsDescription: labsInfo.description,
  126. labsIcon: labsInfo.icon,
  127. versions: [
  128. {
  129. versionNumber: 1,
  130. phase: configuration.phase,
  131. active: configuration.active,
  132. analyticsEnabled:
  133. configuration.active && configuration.analyticsEnabled,
  134. variants: stripedVariants,
  135. author: userId,
  136. },
  137. ],
  138. expectedEndDate: info.expectedEndDate,
  139. expectedUplift: info.expectedUplift,
  140. requiredCohortSize: info.requiredCohortSize,
  141. })
  142. return _saveSplitTest(splitTest)
  143. }
  144. async function updateSplitTestConfig({ name, configuration, comment }, userId) {
  145. const splitTest = await getSplitTest({ name })
  146. if (!splitTest) {
  147. throw new Errors.NotFoundError(
  148. `Cannot update split test '${name}': not found`
  149. )
  150. }
  151. if (splitTest.archived) {
  152. throw new Errors.InvalidError('Cannot update an archived split test', {
  153. name,
  154. })
  155. }
  156. const lastVersion = SplitTestUtils.getCurrentVersion(splitTest).toObject()
  157. if (configuration.phase !== lastVersion.phase) {
  158. throw new Errors.InvalidError(
  159. `Cannot update with different phase - use /switch-to-next-phase endpoint instead`
  160. )
  161. }
  162. _checkNewVariantsConfiguration(
  163. lastVersion.variants,
  164. configuration.variants,
  165. configuration.analyticsEnabled
  166. )
  167. const updatedVariants = _updateVariantsWithNewConfiguration(
  168. lastVersion.variants,
  169. configuration.variants
  170. )
  171. splitTest.versions.push({
  172. versionNumber: lastVersion.versionNumber + 1,
  173. phase: configuration.phase,
  174. active: configuration.active,
  175. analyticsEnabled: configuration.analyticsEnabled,
  176. variants: updatedVariants,
  177. author: userId,
  178. comment,
  179. })
  180. return _saveSplitTest(splitTest)
  181. }
  182. async function updateSplitTestInfo(name, info, labsInfo) {
  183. const splitTest = await getSplitTest({ name })
  184. if (!splitTest) {
  185. throw new Errors.NotFoundError(
  186. `Cannot update split test '${name}': not found`
  187. )
  188. }
  189. splitTest.description = info.description
  190. splitTest.expectedEndDate = info.expectedEndDate
  191. splitTest.ticketUrl = info.ticketUrl
  192. splitTest.reportsUrls = info.reportsUrls
  193. splitTest.winningVariant = info.winningVariant
  194. if (labsInfo) {
  195. splitTest.labsTitle = labsInfo.title
  196. splitTest.labsDescription = labsInfo.description
  197. splitTest.labsIcon = labsInfo.icon
  198. }
  199. return _saveSplitTest(splitTest)
  200. }
  201. async function updateSplitTestBadgeInfo(name, badgeInfo) {
  202. const splitTest = await getSplitTest({ name })
  203. if (!splitTest) {
  204. throw new Errors.NotFoundError(
  205. `Cannot update split test '${name}': not found`
  206. )
  207. }
  208. splitTest.badgeInfo = badgeInfo
  209. return _saveSplitTest(splitTest)
  210. }
  211. async function replaceSplitTests(tests) {
  212. _checkEnvIsSafe('replace')
  213. try {
  214. await _deleteSplitTests()
  215. return await SplitTest.create(tests)
  216. } catch (error) {
  217. throw OError.tag(error, 'Failed to replace all split tests', { tests })
  218. }
  219. }
  220. async function mergeSplitTests(incomingTests, overWriteLocal) {
  221. _checkEnvIsSafe('merge')
  222. // this is required as the query returns models, and we need all the items to be objects,
  223. // similar to the ones we recieve as incomingTests
  224. const localTests = await SplitTest.find({}).lean().exec()
  225. let merged
  226. // we preserve the state of the local tests (baseTests)
  227. // eg: if inTest is in phase 1, and basetest is in phase 2, the merged will be in state 2
  228. // therefore, we can have the opposite effect by swapping the order of args (overwrite locals with sent tests)
  229. if (overWriteLocal) {
  230. merged = _mergeFlags(localTests, incomingTests)
  231. } else {
  232. merged = _mergeFlags(incomingTests, localTests)
  233. }
  234. try {
  235. await _deleteSplitTests()
  236. const success = await SplitTest.create(merged)
  237. return success
  238. } catch (error) {
  239. throw OError.tag(error, 'Failed to merge all split tests, merged set was', {
  240. merged,
  241. })
  242. }
  243. }
  244. async function switchToNextPhase(
  245. { name, comment, targetPhase, labsUserLimit, clearUserLimit },
  246. userId
  247. ) {
  248. const splitTest = await getSplitTest({ name })
  249. if (!splitTest) {
  250. throw new Errors.NotFoundError(
  251. `Cannot switch split test with ID '${name}' to next phase: not found`
  252. )
  253. }
  254. if (splitTest.archived) {
  255. throw new Errors.InvalidError(
  256. 'Cannot switch an archived split test to next phase',
  257. {
  258. name,
  259. }
  260. )
  261. }
  262. const lastVersionCopy = SplitTestUtils.getCurrentVersion(splitTest).toObject()
  263. lastVersionCopy.versionNumber++
  264. const currentPhase = lastVersionCopy.phase
  265. // Determine and validate target phase
  266. if (targetPhase) {
  267. const validTransitions = {
  268. [ALPHA_PHASE]: [LABS_PHASE, BETA_PHASE],
  269. [LABS_PHASE]: [BETA_PHASE],
  270. [BETA_PHASE]: [RELEASE_PHASE],
  271. }
  272. const allowed = validTransitions[currentPhase]
  273. if (!allowed || !allowed.includes(targetPhase)) {
  274. throw new Errors.InvalidError(
  275. `Cannot switch from '${currentPhase}' to '${targetPhase}'`,
  276. { name }
  277. )
  278. }
  279. lastVersionCopy.phase = targetPhase
  280. } else {
  281. // Default transitions (skip labs)
  282. if (currentPhase === ALPHA_PHASE) {
  283. lastVersionCopy.phase = BETA_PHASE
  284. } else if (currentPhase === LABS_PHASE) {
  285. lastVersionCopy.phase = BETA_PHASE
  286. } else if (currentPhase === BETA_PHASE) {
  287. if (splitTest.forbidReleasePhase) {
  288. throw new Errors.ForbiddenError(
  289. 'Switch to release phase is disabled for this test',
  290. {
  291. name,
  292. }
  293. )
  294. }
  295. lastVersionCopy.phase = RELEASE_PHASE
  296. } else if (currentPhase === RELEASE_PHASE) {
  297. throw new Errors.InvalidError(
  298. `Split test with ID '${name}' is already in the release phase`
  299. )
  300. }
  301. }
  302. if (splitTest.forbidReleasePhase && lastVersionCopy.phase === RELEASE_PHASE) {
  303. throw new Errors.ForbiddenError(
  304. 'Switch to release phase is disabled for this test',
  305. {
  306. name,
  307. }
  308. )
  309. }
  310. // Labs phase requires labs config to be set
  311. if (lastVersionCopy.phase === LABS_PHASE) {
  312. if (!splitTest.labsTitle || !splitTest.labsDescription) {
  313. // Cannot be a required fields as we do not always promote to labs
  314. throw new Errors.InvalidError(
  315. 'Labs phase requires Labs Title and Labs Description to be set',
  316. { name }
  317. )
  318. }
  319. }
  320. // Labs phase requires exactly one variant
  321. if (lastVersionCopy.phase === LABS_PHASE) {
  322. if (lastVersionCopy.variants.length !== 1) {
  323. throw new Errors.InvalidError('Labs phase requires exactly one variant', {
  324. name,
  325. variantCount: lastVersionCopy.variants.length,
  326. })
  327. }
  328. // Auto-set variant to 100% rollout for labs
  329. const variant = lastVersionCopy.variants[0]
  330. variant.rolloutPercent = 100
  331. variant.rolloutStripes = [{ start: 0, end: 100 }]
  332. if (variant.userLimit === undefined) {
  333. variant.userLimit = labsUserLimit
  334. }
  335. variant.userCount = 0
  336. } else {
  337. for (const variant of lastVersionCopy.variants) {
  338. variant.rolloutPercent = 0
  339. variant.rolloutStripes = []
  340. if (clearUserLimit) {
  341. delete variant.userLimit
  342. delete variant.userCount
  343. } else if (variant.userCount) {
  344. variant.userCount = 0
  345. }
  346. }
  347. }
  348. lastVersionCopy.author = userId
  349. lastVersionCopy.comment = comment
  350. lastVersionCopy.createdAt = new Date()
  351. splitTest.versions.push(lastVersionCopy)
  352. return _saveSplitTest(splitTest)
  353. }
  354. async function revertToPreviousVersion(
  355. { name, versionNumber, comment },
  356. userId
  357. ) {
  358. const splitTest = await getSplitTest({ name })
  359. if (!splitTest) {
  360. throw new Errors.NotFoundError(
  361. `Cannot revert split test with ID '${name}' to previous version: not found`
  362. )
  363. }
  364. if (splitTest.archived) {
  365. throw new Errors.InvalidError(
  366. 'Cannot revert an archived split test to previous version',
  367. {
  368. name,
  369. }
  370. )
  371. }
  372. if (splitTest.versions.length <= 1) {
  373. throw new Errors.InvalidError(
  374. `Cannot revert split test with ID '${name}' to previous version: split test must have at least 2 versions`
  375. )
  376. }
  377. const previousVersion = SplitTestUtils.getVersion(splitTest, versionNumber)
  378. if (!previousVersion) {
  379. throw new Errors.NotFoundError(
  380. `Cannot revert split test with ID '${name}' to version number ${versionNumber}: version not found`
  381. )
  382. }
  383. const lastVersion = SplitTestUtils.getCurrentVersion(splitTest)
  384. if (
  385. lastVersion.phase === RELEASE_PHASE &&
  386. previousVersion.phase !== RELEASE_PHASE
  387. ) {
  388. splitTest.forbidReleasePhase = true
  389. }
  390. const previousVersionCopy = previousVersion.toObject()
  391. previousVersionCopy.versionNumber = lastVersion.versionNumber + 1
  392. previousVersionCopy.createdAt = new Date()
  393. previousVersionCopy.author = userId
  394. previousVersionCopy.comment = comment
  395. // restore user count from most recent version of this phase
  396. const mostRecentVersionOfTargetPhase = splitTest.versions.findLast(
  397. v => v.phase === previousVersion.phase
  398. )
  399. if (mostRecentVersionOfTargetPhase) {
  400. for (const variant of previousVersionCopy.variants) {
  401. const correspondingVariant = mostRecentVersionOfTargetPhase.variants.find(
  402. v => v.name === variant.name
  403. )
  404. if (correspondingVariant?.userCount) {
  405. variant.userCount = correspondingVariant.userCount
  406. }
  407. }
  408. } else {
  409. for (const variant of previousVersionCopy.variants) {
  410. if (variant.userCount) {
  411. variant.userCount = 0
  412. }
  413. }
  414. }
  415. splitTest.versions.push(previousVersionCopy)
  416. return _saveSplitTest(splitTest)
  417. }
  418. async function archive(name, userId) {
  419. const splitTest = await getSplitTest({ name })
  420. if (!splitTest) {
  421. throw new Errors.NotFoundError(
  422. `Cannot archive split test with ID '${name}': not found`
  423. )
  424. }
  425. if (splitTest.archived) {
  426. throw new Errors.InvalidError(
  427. `Split test with ID '${name}' is already archived`
  428. )
  429. }
  430. splitTest.archived = true
  431. splitTest.archivedAt = new Date()
  432. splitTest.archivedBy = userId
  433. return _saveSplitTest(splitTest)
  434. }
  435. async function clearCache() {
  436. await CacheFlow.reset('split-test')
  437. }
  438. function _checkNewVariantsConfiguration(
  439. variants,
  440. newVariantsConfiguration,
  441. analyticsEnabled
  442. ) {
  443. if (newVariantsConfiguration?.length > 1 && !analyticsEnabled) {
  444. throw new Errors.InvalidError(
  445. `Gradual rollouts can only have a single variant`
  446. )
  447. }
  448. const totalRolloutPercentage = _getTotalRolloutPercentage(
  449. newVariantsConfiguration
  450. )
  451. if (totalRolloutPercentage > 100) {
  452. throw new Errors.InvalidError(
  453. `Total variants rollout percentage cannot exceed 100`
  454. )
  455. }
  456. for (const variant of variants) {
  457. const newVariantConfiguration = _.find(newVariantsConfiguration, {
  458. name: variant.name,
  459. })
  460. if (!newVariantConfiguration) {
  461. throw new Errors.InvalidError(
  462. `Variant defined in previous version as ${JSON.stringify(
  463. variant
  464. )} cannot be removed in new configuration: either set it inactive or create a new split test`
  465. )
  466. }
  467. if (newVariantConfiguration.rolloutPercent < variant.rolloutPercent) {
  468. throw new Errors.InvalidError(
  469. `Rollout percentage for variant defined in previous version as ${JSON.stringify(
  470. variant
  471. )} cannot be decreased: revert to a previous configuration instead`
  472. )
  473. }
  474. if (variant.userLimit !== undefined) {
  475. // Existing variant has a user limit - can only increase it
  476. if (
  477. newVariantConfiguration.userLimit !== undefined &&
  478. newVariantConfiguration.userLimit < variant.userLimit
  479. ) {
  480. throw new Errors.InvalidError(
  481. `User limit for variant '${variant.name}' cannot be decreased: revert to a previous configuration instead`
  482. )
  483. }
  484. } else {
  485. // Existing variant has no user limit - cannot add one
  486. if (newVariantConfiguration.userLimit !== undefined) {
  487. throw new Errors.InvalidError(
  488. `User limit cannot be added to variant '${variant.name}' after creation: user limits can only be set when the split test is created`
  489. )
  490. }
  491. }
  492. }
  493. }
  494. function _updateVariantsWithNewConfiguration(
  495. variants,
  496. newVariantsConfiguration
  497. ) {
  498. let totalRolloutPercentage = _getTotalRolloutPercentage(variants)
  499. const variantsCopy = _.clone(variants)
  500. for (const newVariantConfig of newVariantsConfiguration) {
  501. if (newVariantConfig.rolloutPercent === 0) {
  502. continue
  503. }
  504. const variant = _.find(variantsCopy, { name: newVariantConfig.name })
  505. if (!variant) {
  506. const newVariant = {
  507. name: newVariantConfig.name,
  508. rolloutPercent: newVariantConfig.rolloutPercent,
  509. rolloutStripes: [
  510. {
  511. start: totalRolloutPercentage,
  512. end: totalRolloutPercentage + newVariantConfig.rolloutPercent,
  513. },
  514. ],
  515. }
  516. if (
  517. newVariantConfig.userLimit &&
  518. typeof newVariantConfig.userLimit === 'number'
  519. ) {
  520. newVariant.userLimit = newVariantConfig.userLimit
  521. newVariant.userCount = 0
  522. }
  523. variantsCopy.push(newVariant)
  524. totalRolloutPercentage += newVariantConfig.rolloutPercent
  525. } else if (variant.rolloutPercent < newVariantConfig.rolloutPercent) {
  526. const newStripeSize =
  527. newVariantConfig.rolloutPercent - variant.rolloutPercent
  528. variant.rolloutPercent = newVariantConfig.rolloutPercent
  529. variant.rolloutStripes.push({
  530. start: totalRolloutPercentage,
  531. end: totalRolloutPercentage + newStripeSize,
  532. })
  533. totalRolloutPercentage += newStripeSize
  534. }
  535. if (newVariantConfig.userLimit >= variant?.userLimit) {
  536. variant.userLimit = newVariantConfig.userLimit
  537. }
  538. if (variant?.userLimit && !variant.userCount) {
  539. variant.userCount = 0
  540. }
  541. }
  542. return variantsCopy
  543. }
  544. function _getTotalRolloutPercentage(variants) {
  545. return _.sumBy(variants, 'rolloutPercent')
  546. }
  547. async function _saveSplitTest(splitTest) {
  548. try {
  549. const savedSplitTest = await splitTest.save()
  550. await savedSplitTest.populate('archivedBy', [
  551. 'email',
  552. 'first_name',
  553. 'last_name',
  554. ])
  555. await savedSplitTest.populate('versions.author', [
  556. 'email',
  557. 'first_name',
  558. 'last_name',
  559. ])
  560. return savedSplitTest.toObject()
  561. } catch (error) {
  562. throw OError.tag(error, 'Failed to save split test', {
  563. splitTest: JSON.stringify(splitTest),
  564. })
  565. }
  566. }
  567. /*
  568. * As this is only used for utility in local dev environment, we should make sure this isn't run in
  569. * any other deployment environment.
  570. */
  571. function _checkEnvIsSafe(operation) {
  572. if (process.env.NODE_ENV !== 'development') {
  573. throw new Errors.ForbiddenError(
  574. `Attempted to ${operation} all feature flags outside of local env`
  575. )
  576. }
  577. }
  578. async function _deleteSplitTests() {
  579. _checkEnvIsSafe('delete')
  580. let deleted
  581. try {
  582. deleted = await SplitTest.deleteMany({}).exec()
  583. } catch (error) {
  584. throw new OError('Failed to delete all split tests')
  585. }
  586. if (!deleted.acknowledged) {
  587. throw new OError('Error deleting split tests, split tests have not updated')
  588. }
  589. }
  590. function _mergeFlags(incomingTests, baseTests) {
  591. // copy all base versions
  592. const mergedSet = baseTests.map(test => test)
  593. for (const inTest of incomingTests) {
  594. // since name is a unique key, we can use it to compare
  595. const newFeatureFlag = !mergedSet.some(bTest => bTest.name === inTest.name)
  596. // only add new feature flags, instead of overwriting ones in baseTests, meaning baseTests take precendence
  597. if (newFeatureFlag) {
  598. mergedSet.push(inTest)
  599. }
  600. }
  601. return mergedSet
  602. }
  603. export default {
  604. getSplitTest,
  605. getSplitTests,
  606. getRuntimeTests,
  607. createSplitTest,
  608. updateSplitTestConfig,
  609. updateSplitTestInfo,
  610. updateSplitTestBadgeInfo,
  611. switchToNextPhase,
  612. revertToPreviousVersion,
  613. archive,
  614. replaceSplitTests,
  615. mergeSplitTests,
  616. clearCache,
  617. }