SplitTestManager.mjs 16 KB

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