SplitTestHandler.mjs 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064
  1. import Metrics from '@overleaf/metrics'
  2. import UserUpdater from '../User/UserUpdater.mjs'
  3. import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
  4. import LocalsHelper from './LocalsHelper.mjs'
  5. import crypto from 'node:crypto'
  6. import _ from 'lodash'
  7. import { callbackify } from 'node:util'
  8. import SplitTestCache from './SplitTestCache.mjs'
  9. import { SplitTest } from '../../models/SplitTest.mjs'
  10. import UserAnalyticsDataCache from '../Analytics/UserAnalyticsDataCache.mjs'
  11. import Features from '../../infrastructure/Features.mjs'
  12. import SplitTestUtils from './SplitTestUtils.mjs'
  13. import Settings from '@overleaf/settings'
  14. import SessionManager from '../Authentication/SessionManager.mjs'
  15. import logger from '@overleaf/logger'
  16. import SplitTestSessionHandler from './SplitTestSessionHandler.mjs'
  17. import SplitTestUserGetter from './SplitTestUserGetter.mjs'
  18. /**
  19. * @import { Assignment } from "./types"
  20. */
  21. const DEFAULT_VARIANT = 'default'
  22. const ALPHA_PHASE = 'alpha'
  23. const LABS_PHASE = 'labs'
  24. const BETA_PHASE = 'beta'
  25. const RELEASE_PHASE = 'release'
  26. const DEFAULT_ASSIGNMENT = {
  27. variant: DEFAULT_VARIANT,
  28. metadata: {},
  29. }
  30. /**
  31. * Get the assignment of a user to a split test and store it in the response locals context
  32. *
  33. * @example
  34. * // Assign user and record an event
  35. *
  36. * const assignment = await SplitTestHandler.getAssignment(req, res, 'example-project')
  37. * if (assignment.variant === 'awesome-new-version') {
  38. * // execute my awesome change
  39. * }
  40. * else {
  41. * // execute the default behaviour (control group)
  42. * }
  43. *
  44. * @param req the request
  45. * @param res the Express response object
  46. * @param splitTestName the unique name of the split test
  47. * @param {Object} options
  48. * @param {boolean} options.sync - for test purposes only, to force the synchronous update of the user's profile
  49. * @param {boolean} options.includeReferer For ajax requests and downloads include the split test overrides of the page
  50. * @returns {Promise<Assignment>}
  51. */
  52. async function getAssignment(
  53. req,
  54. res,
  55. splitTestName,
  56. { sync = false, includeReferer = false } = {}
  57. ) {
  58. let assignment
  59. try {
  60. if (!Features.hasFeature('saas')) {
  61. assignment = _getNonSaasAssignment(splitTestName)
  62. } else {
  63. await _loadSplitTestInfoInLocals(res.locals, splitTestName, req.session)
  64. let query = req.query || {}
  65. if (includeReferer && req.headers.referer) {
  66. // Pick up the query of the top-level page, i.e. what's in the browsers address bar, from ajax requests.
  67. // E.g. /project/:id?split-test=foo -> ajax /project/:id/compile should see split-test=foo.
  68. // E.g. /project/:id?split-test=foo -> redirect /project/:id/download/zip should see split-test=foo.
  69. try {
  70. const u = new URL(req.headers.referer, Settings.siteUrl)
  71. query = {
  72. ...Object.fromEntries(u.searchParams.entries()),
  73. ...query,
  74. }
  75. } catch {}
  76. }
  77. // Check the query string for an override, ignoring an invalid value
  78. const queryVariant = query[splitTestName]
  79. if (queryVariant) {
  80. const variants = await _getVariantNames(splitTestName)
  81. if (variants.includes(queryVariant)) {
  82. assignment = {
  83. variant: queryVariant,
  84. metadata: {},
  85. }
  86. }
  87. }
  88. if (!assignment) {
  89. const { userId, analyticsId } = AnalyticsManager.getIdsFromSession(
  90. req.session
  91. )
  92. assignment = await _getAssignment(splitTestName, {
  93. analyticsId,
  94. userId,
  95. session: req.session,
  96. sync,
  97. })
  98. SplitTestSessionHandler.collectSessionStats(req.session)
  99. }
  100. }
  101. } catch (error) {
  102. logger.error({ err: error }, 'Failed to get split test assignment')
  103. assignment = DEFAULT_ASSIGNMENT
  104. }
  105. LocalsHelper.setSplitTestVariant(
  106. res.locals,
  107. splitTestName,
  108. assignment.variant
  109. )
  110. return assignment
  111. }
  112. /**
  113. * Get the assignment of a user to a split test by their user ID.
  114. *
  115. * Warning: this does not support query parameters override, nor makes the assignment and split test info available to
  116. * the frontend through locals. Wherever possible, `getAssignment` should be used instead.
  117. *
  118. * @param userId the user ID
  119. * @param splitTestName the unique name of the split test
  120. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  121. * @returns {Promise<Assignment>}
  122. */
  123. async function getAssignmentForUser(
  124. userId,
  125. splitTestName,
  126. { sync = false } = {}
  127. ) {
  128. try {
  129. if (!Features.hasFeature('saas')) {
  130. return _getNonSaasAssignment(splitTestName)
  131. }
  132. const analyticsId = await UserAnalyticsDataCache.getAnalyticsId(
  133. userId,
  134. `getAssignmentForUser:${splitTestName}`
  135. )
  136. return _getAssignment(splitTestName, { analyticsId, userId, sync })
  137. } catch (error) {
  138. logger.error({ err: error }, 'Failed to get split test assignment for user')
  139. return DEFAULT_ASSIGNMENT
  140. }
  141. }
  142. /**
  143. * Returns true if user has already been explicitly assigned to a variant.
  144. * This will be false if the user **would** be assigned when calling getAssignment but hasn't yet.
  145. *
  146. * @param req express request
  147. * @param {string} userId the user ID
  148. * @param {string} splitTestName the unique name of the split test
  149. * @param {string} variant variant name to check
  150. * @param {boolean} ignoreVersion users explicitly assigned to a previous version should be treated as if assigned to latest version
  151. */
  152. async function hasUserBeenAssignedToVariant(
  153. req,
  154. userId,
  155. splitTestName,
  156. variant,
  157. ignoreVersion = false
  158. ) {
  159. try {
  160. const { session = {}, query = {} } = req
  161. const splitTest = await _getSplitTest(splitTestName)
  162. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  163. if (!userId || !currentVersion?.active) {
  164. return false
  165. }
  166. // Check the query string for an override, ignoring an invalid value
  167. const queryVariant = query[splitTestName]
  168. if (queryVariant === variant) {
  169. const variants = await _getVariantNames(splitTestName)
  170. if (variants.includes(queryVariant)) {
  171. return true
  172. }
  173. }
  174. // Allow dev toolbar and session cache to override assignment from DB
  175. if (Settings.devToolbar.enabled) {
  176. const override = session?.splitTestOverrides?.[splitTestName]
  177. if (override === variant) {
  178. return true
  179. }
  180. }
  181. const canUseSessionCache = session && SessionManager.isUserLoggedIn(session)
  182. if (canUseSessionCache) {
  183. const cachedVariant = SplitTestSessionHandler.getCachedVariant(
  184. session,
  185. splitTestName,
  186. currentVersion
  187. )
  188. if (cachedVariant === variant) {
  189. return true
  190. }
  191. }
  192. // get variant from db, including explicit assignments from previous versions if requested
  193. const assignments = await getActiveAssignmentsForUser(
  194. userId,
  195. true,
  196. ignoreVersion
  197. )
  198. const testAssignment = assignments[splitTestName]
  199. if (!testAssignment || !testAssignment.assignedAt) {
  200. return false
  201. }
  202. // if variant matches and we can use cache, we should persist it in cache
  203. if (testAssignment.variantName === variant && testAssignment.assignedAt) {
  204. if (canUseSessionCache) {
  205. SplitTestSessionHandler.setVariantInCache({
  206. session,
  207. splitTestName,
  208. currentVersion,
  209. selectedVariantName: variant,
  210. activeForUser: true,
  211. })
  212. }
  213. return true
  214. }
  215. } catch (error) {
  216. logger.error({ err: error }, 'Failed to get split test assignment for user')
  217. return false
  218. }
  219. }
  220. /**
  221. * Get a mapping of the active split test assignments for the given user
  222. */
  223. async function getActiveAssignmentsForUser(
  224. userId,
  225. removeArchived = false,
  226. ignoreVersion = false
  227. ) {
  228. if (!Features.hasFeature('saas')) {
  229. return {}
  230. }
  231. const user = await SplitTestUserGetter.promises.getUser(
  232. userId,
  233. null,
  234. 'getActiveAssignmentsForUser'
  235. )
  236. if (user == null) {
  237. return {}
  238. }
  239. const splitTests = (await SplitTestCache.get('')).values()
  240. const assignments = {}
  241. for (const splitTest of splitTests) {
  242. if (!splitTest.versions[splitTest.versions.length - 1].active) continue
  243. if (removeArchived && splitTest.archived) continue
  244. const { activeForUser, selectedVariantName, phase, versionNumber } =
  245. await _getAssignmentMetadata(user.analyticsId, user, splitTest)
  246. if (activeForUser) {
  247. const assignment = {
  248. variantName: selectedVariantName,
  249. versionNumber,
  250. phase,
  251. }
  252. const userAssignments = user.splitTests?.[splitTest.name]
  253. if (Array.isArray(userAssignments)) {
  254. let userAssignment
  255. if (!ignoreVersion) {
  256. userAssignment = userAssignments.find(
  257. x => x.versionNumber === versionNumber
  258. )
  259. } else {
  260. userAssignment = userAssignments[0]
  261. }
  262. if (userAssignment) {
  263. assignment.assignedAt = userAssignment.assignedAt
  264. }
  265. }
  266. assignments[splitTest.name] = assignment
  267. }
  268. }
  269. return assignments
  270. }
  271. /**
  272. * Performs a one-time assignment that is not recorded nor reproducible.
  273. * To be used only in cases where we need random assignments that are independent of a user or session.
  274. * If the test is in alpha or beta phase, always returns the default variant.
  275. * @param splitTestName
  276. * @returns {Promise<Assignment>}
  277. */
  278. async function getOneTimeAssignment(splitTestName) {
  279. try {
  280. if (!Features.hasFeature('saas')) {
  281. return _getNonSaasAssignment(splitTestName)
  282. }
  283. const splitTest = await _getSplitTest(splitTestName)
  284. if (!splitTest) {
  285. return DEFAULT_ASSIGNMENT
  286. }
  287. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  288. if (currentVersion.phase !== RELEASE_PHASE) {
  289. return DEFAULT_ASSIGNMENT
  290. }
  291. const randomUUID = crypto.randomUUID()
  292. const { selectedVariantName } = await _getAssignmentMetadata(
  293. randomUUID,
  294. undefined,
  295. splitTest
  296. )
  297. return _makeAssignment({
  298. variant: selectedVariantName,
  299. currentVersion,
  300. isFirstNonDefaultAssignment:
  301. selectedVariantName !== DEFAULT_VARIANT && _isSplitTest(splitTest),
  302. })
  303. } catch (error) {
  304. logger.error({ err: error }, 'Failed to get one time split test assignment')
  305. return DEFAULT_ASSIGNMENT
  306. }
  307. }
  308. /**
  309. * Checks if a feature flag is enabled for a specific user
  310. *
  311. * Retrieves the feature flag assignment for a user and determines if the assigned variant is 'enabled'
  312. *
  313. * @param req the request
  314. * @param res the Express response object
  315. * @param {string} splitTestName - The unique name of the feature flag
  316. * @param {Object} options
  317. * @param {boolean} options.includeReferer For ajax requests and downloads include the split test overrides of the page
  318. * @returns {Promise<boolean>} True if the user's assigned variant is 'enabled', false otherwise
  319. */
  320. async function featureFlagEnabled(
  321. req,
  322. res,
  323. splitTestName,
  324. { includeReferer = false } = { includeReferer: false }
  325. ) {
  326. const { variant } = await getAssignment(req, res, splitTestName, {
  327. includeReferer,
  328. })
  329. return variant === 'enabled'
  330. }
  331. /**
  332. * Checks if a feature flag is enabled for a specific user
  333. *
  334. * Retrieves the feature flag assignment for a user and determines if the assigned variant is 'enabled'
  335. *
  336. * @param {string} userId - The ID of the user to check the feature flag for
  337. * @param {string} splitTestName - The unique name of the feature flag
  338. * @returns {Promise<boolean>} True if the user's assigned variant is 'enabled', false otherwise
  339. */
  340. async function featureFlagEnabledForUser(userId, splitTestName) {
  341. const { variant } = await getAssignmentForUser(userId, splitTestName)
  342. return variant === 'enabled'
  343. }
  344. /**
  345. * Returns an array of valid variant names for the given split test, including default
  346. *
  347. * @param splitTestName
  348. * @returns {Promise<string[]>}
  349. * @private
  350. */
  351. async function _getVariantNames(splitTestName) {
  352. const splitTest = await _getSplitTest(splitTestName)
  353. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  354. if (currentVersion?.active) {
  355. return currentVersion.variants.map(v => v.name).concat([DEFAULT_VARIANT])
  356. } else {
  357. return [DEFAULT_VARIANT]
  358. }
  359. }
  360. async function _getAssignment(
  361. splitTestName,
  362. { analyticsId, user, userId, session, sync }
  363. ) {
  364. if (!analyticsId && !userId) {
  365. return DEFAULT_ASSIGNMENT
  366. }
  367. const splitTest = await _getSplitTest(splitTestName)
  368. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  369. if (Settings.devToolbar.enabled) {
  370. const override = session?.splitTestOverrides?.[splitTestName]
  371. if (override) {
  372. return _makeAssignment({ variant: override, currentVersion })
  373. }
  374. }
  375. if (!currentVersion?.active) {
  376. return DEFAULT_ASSIGNMENT
  377. }
  378. // Do not cache assignments for anonymous users. All the context for their assignments is in the session:
  379. // They cannot be part of the alpha or beta program, and they will use their analyticsId for assignments.
  380. const canUseSessionCache = session && SessionManager.isUserLoggedIn(session)
  381. if (session && !canUseSessionCache) {
  382. // Purge the existing cache
  383. delete session.cachedSplitTestAssignments
  384. }
  385. if (canUseSessionCache) {
  386. const cachedVariant = SplitTestSessionHandler.getCachedVariant(
  387. session,
  388. splitTest.name,
  389. currentVersion
  390. )
  391. if (cachedVariant) {
  392. Metrics.inc('split_test_get_assignment_source', 1, { status: 'cache' })
  393. if (
  394. cachedVariant ===
  395. SplitTestSessionHandler.CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER
  396. ) {
  397. return DEFAULT_ASSIGNMENT
  398. } else {
  399. return _makeAssignment({
  400. variant: cachedVariant,
  401. currentVersion,
  402. isFirstNonDefaultAssignment: false,
  403. })
  404. }
  405. }
  406. }
  407. if (user) {
  408. Metrics.inc('split_test_get_assignment_source', 1, { status: 'provided' })
  409. } else if (userId) {
  410. Metrics.inc('split_test_get_assignment_source', 1, { status: 'mongo' })
  411. } else {
  412. Metrics.inc('split_test_get_assignment_source', 1, { status: 'none' })
  413. }
  414. user =
  415. user ||
  416. (userId &&
  417. (await SplitTestUserGetter.promises.getUser(
  418. userId,
  419. splitTestName,
  420. `_getAssignment:${splitTestName}`
  421. )))
  422. const metadata = await _getAssignmentMetadata(analyticsId, user, splitTest)
  423. const { activeForUser, selectedVariantName, phase, versionNumber } = metadata
  424. if (canUseSessionCache) {
  425. SplitTestSessionHandler.setVariantInCache({
  426. session,
  427. splitTestName,
  428. currentVersion,
  429. selectedVariantName,
  430. activeForUser,
  431. })
  432. }
  433. if (activeForUser) {
  434. const hasUserLimit = _currentVersionHasUserLimit(splitTest)
  435. if (_isSplitTest(splitTest) || hasUserLimit) {
  436. // if the user is logged in, persist the assignment (and increment user count if needed)
  437. if (userId) {
  438. const assignmentData = {
  439. user,
  440. userId,
  441. splitTestName,
  442. phase,
  443. versionNumber,
  444. variantName: selectedVariantName,
  445. }
  446. if (sync === true) {
  447. await _recordAssignment(assignmentData)
  448. } else {
  449. _recordAssignment(assignmentData).catch(err => {
  450. logger.warn(
  451. {
  452. err,
  453. userId,
  454. splitTestName,
  455. phase,
  456. versionNumber,
  457. variantName: selectedVariantName,
  458. },
  459. 'failed to record split test assignment'
  460. )
  461. })
  462. }
  463. }
  464. // otherwise this is an anonymous user, we store assignments in session to persist them on registration
  465. else if (_isSplitTest(splitTest)) {
  466. await SplitTestSessionHandler.promises.appendAssignment(session, {
  467. splitTestId: splitTest._id,
  468. splitTestName,
  469. phase,
  470. versionNumber,
  471. variantName: selectedVariantName,
  472. assignedAt: new Date(),
  473. })
  474. }
  475. if (_isSplitTest(splitTest)) {
  476. const effectiveAnalyticsId = user?.analyticsId || analyticsId || userId
  477. AnalyticsManager.setUserPropertyForAnalyticsId(
  478. effectiveAnalyticsId,
  479. `split-test-${splitTestName}-${versionNumber}`,
  480. selectedVariantName
  481. ).catch(err => {
  482. logger.warn(
  483. {
  484. err,
  485. analyticsId: effectiveAnalyticsId,
  486. splitTest: splitTestName,
  487. versionNumber,
  488. variant: selectedVariantName,
  489. },
  490. 'failed to set user property for analytics id'
  491. )
  492. })
  493. }
  494. }
  495. let isFirstNonDefaultAssignment
  496. if (userId) {
  497. isFirstNonDefaultAssignment = metadata.isFirstNonDefaultAssignment
  498. } else {
  499. const assignments =
  500. await SplitTestSessionHandler.promises.getAssignments(session)
  501. isFirstNonDefaultAssignment = !assignments?.[splitTestName]
  502. }
  503. return _makeAssignment({
  504. variant: selectedVariantName,
  505. currentVersion,
  506. isFirstNonDefaultAssignment,
  507. })
  508. }
  509. return DEFAULT_ASSIGNMENT
  510. }
  511. async function _getAssignmentMetadata(analyticsId, user, splitTest) {
  512. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  513. const versionNumber = currentVersion.versionNumber
  514. const phase = currentVersion.phase
  515. // For continuity on phase rollout for gradual rollouts, we keep all users from the previous phase enrolled to the variant.
  516. // In beta, all alpha and labs users are cohorted to the variant, and the same in release phase all alpha, labs & beta users.
  517. if (
  518. _isGradualRollout(splitTest) &&
  519. ((phase === BETA_PHASE && user?.alphaProgram) ||
  520. (phase === RELEASE_PHASE &&
  521. (user?.alphaProgram ||
  522. user?.betaProgram ||
  523. (user?.labsProgram &&
  524. user?.labsExperiments?.includes(splitTest.name)))))
  525. ) {
  526. return {
  527. activeForUser: true,
  528. selectedVariantName: currentVersion.variants[0].name,
  529. phase,
  530. versionNumber,
  531. isFirstNonDefaultAssignment: false,
  532. }
  533. }
  534. // Labs phase: user must be in labs program AND have opted into this experiment.
  535. // The userCount/userLimit check is enforced at enrollment time (see
  536. // incrementLabsVariantCounterIfBelowLimit), so we trust the enrollment here.
  537. if (phase === LABS_PHASE) {
  538. if (user?.labsProgram && user?.labsExperiments?.includes(splitTest.name)) {
  539. const selectedVariant = currentVersion.variants[0]
  540. const selectedVariantName = selectedVariant.name
  541. return {
  542. activeForUser: true,
  543. selectedVariantName,
  544. phase,
  545. versionNumber,
  546. isFirstNonDefaultAssignment: false,
  547. }
  548. }
  549. return {
  550. activeForUser: false,
  551. }
  552. }
  553. if (
  554. (phase === ALPHA_PHASE && !user?.alphaProgram) ||
  555. (phase === BETA_PHASE && !user?.betaProgram)
  556. ) {
  557. return {
  558. activeForUser: false,
  559. }
  560. }
  561. const userId = user?._id.toString()
  562. const percentile = getPercentile(analyticsId || userId, splitTest.name, phase)
  563. let selectedVariantName =
  564. _getVariantFromPercentile(currentVersion.variants, percentile) ||
  565. DEFAULT_VARIANT
  566. // Some variants may have a limit on the number of users that can be assigned
  567. if (selectedVariantName !== DEFAULT_VARIANT) {
  568. const selectedVariant = currentVersion.variants.find(
  569. variant => variant.name === selectedVariantName
  570. )
  571. const userLimit = selectedVariant?.userLimit
  572. if (userLimit && typeof userLimit === 'number') {
  573. const userAssignments = user?.splitTests?.[splitTest.name]
  574. const existingAssignment = Array.isArray(userAssignments)
  575. ? userAssignments.find(
  576. assignment =>
  577. assignment.phase === phase &&
  578. assignment.variantName === selectedVariantName
  579. )
  580. : null
  581. if (!existingAssignment) {
  582. const currentCount = selectedVariant.userCount ?? 0
  583. if (currentCount >= userLimit) {
  584. selectedVariantName = DEFAULT_VARIANT
  585. }
  586. }
  587. }
  588. }
  589. return {
  590. activeForUser: true,
  591. selectedVariantName,
  592. phase,
  593. versionNumber,
  594. isFirstNonDefaultAssignment:
  595. selectedVariantName !== DEFAULT_VARIANT &&
  596. _isSplitTest(splitTest) &&
  597. (!Array.isArray(user?.splitTests?.[splitTest.name]) ||
  598. !user?.splitTests?.[splitTest.name]?.some(
  599. assignment => assignment.variantName !== DEFAULT_VARIANT
  600. )),
  601. }
  602. }
  603. function getPercentile(analyticsId, splitTestName, splitTestPhase) {
  604. const hash = crypto
  605. .createHash('md5')
  606. .update(analyticsId + splitTestName + splitTestPhase)
  607. .digest('hex')
  608. const hashPrefix = hash.substr(0, 8)
  609. return Math.floor(
  610. ((parseInt(hashPrefix, 16) % 0xffffffff) / 0xffffffff) * 100
  611. )
  612. }
  613. function setOverrideInSession(session, splitTestName, variantName) {
  614. if (!Settings.devToolbar.enabled) {
  615. return
  616. }
  617. if (!session.splitTestOverrides) {
  618. session.splitTestOverrides = {}
  619. }
  620. session.splitTestOverrides[splitTestName] = variantName
  621. }
  622. function clearOverridesInSession(session) {
  623. delete session.splitTestOverrides
  624. }
  625. function _getVariantFromPercentile(variants, percentile) {
  626. for (const variant of variants) {
  627. for (const stripe of variant.rolloutStripes) {
  628. if (percentile >= stripe.start && percentile < stripe.end) {
  629. return variant.name
  630. }
  631. }
  632. }
  633. }
  634. async function _recordAssignment({
  635. user,
  636. userId,
  637. splitTestName,
  638. phase,
  639. versionNumber,
  640. variantName,
  641. }) {
  642. const persistedAssignment = {
  643. variantName,
  644. versionNumber,
  645. phase,
  646. assignedAt: new Date(),
  647. }
  648. user =
  649. user ||
  650. (await SplitTestUserGetter.promises.getUser(
  651. userId,
  652. splitTestName,
  653. `_recordAssignment:${splitTestName}`
  654. ))
  655. if (user) {
  656. const assignedSplitTests = user.splitTests || []
  657. const assignmentLog = assignedSplitTests[splitTestName] || []
  658. const existingAssignment = _.find(assignmentLog, { versionNumber })
  659. if (!existingAssignment) {
  660. const shouldIncrementCounter = await _shouldIncrementVariantCounter(
  661. splitTestName,
  662. variantName,
  663. phase,
  664. user
  665. )
  666. const updatePromises = [
  667. UserUpdater.promises.updateUser(userId, {
  668. $addToSet: {
  669. [`splitTests.${splitTestName}`]: persistedAssignment,
  670. },
  671. }),
  672. ]
  673. if (shouldIncrementCounter) {
  674. updatePromises.push(
  675. _incrementVariantCounter(splitTestName, variantName, versionNumber)
  676. )
  677. }
  678. await Promise.all(updatePromises)
  679. }
  680. }
  681. }
  682. /**
  683. * Check if the variant counter should be incremented for this assignment
  684. * Only increment for tests with user limits and when user hasn't been assigned to this variant in this phase before
  685. * @param {string} splitTestName - The name of the split test
  686. * @param {string} variantName - The name of the variant
  687. * @param {string} phase - The phase of the split test
  688. * @param {Object} user - The user object
  689. * @returns {Promise<boolean>} Whether the counter should be incremented
  690. */
  691. async function _shouldIncrementVariantCounter(
  692. splitTestName,
  693. variantName,
  694. phase,
  695. user
  696. ) {
  697. if (variantName === DEFAULT_VARIANT) {
  698. return false
  699. }
  700. // Labs variant counters are managed at enrollment/unenrollment time,
  701. // not at assignment time.
  702. if (phase === LABS_PHASE) {
  703. return false
  704. }
  705. const splitTest = await _getSplitTest(splitTestName)
  706. if (!splitTest) {
  707. return false
  708. }
  709. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  710. if (!currentVersion) {
  711. return false
  712. }
  713. const variant = currentVersion.variants.find(v => v.name === variantName)
  714. const hasUserLimit =
  715. variant?.userLimit && typeof variant.userLimit === 'number'
  716. if (!hasUserLimit) {
  717. return false
  718. }
  719. const userAssignments = user?.splitTests?.[splitTest.name]
  720. const existingPhaseAssignment = Array.isArray(userAssignments)
  721. ? userAssignments.find(
  722. assignment =>
  723. assignment.phase === phase && assignment.variantName === variantName
  724. )
  725. : null
  726. // Only increment if user hasn't been assigned to this variant in this phase before
  727. return !existingPhaseAssignment
  728. }
  729. function _makeAssignment({
  730. variant,
  731. currentVersion,
  732. isFirstNonDefaultAssignment,
  733. }) {
  734. return {
  735. variant,
  736. metadata: {
  737. phase: currentVersion.phase,
  738. versionNumber: currentVersion.versionNumber,
  739. isFirstNonDefaultAssignment,
  740. },
  741. }
  742. }
  743. async function _loadSplitTestInfoInLocals(locals, splitTestName, session) {
  744. const splitTest = await _getSplitTest(splitTestName)
  745. if (splitTest) {
  746. const override = session?.splitTestOverrides?.[splitTestName]
  747. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  748. if (!currentVersion.active && !Settings.devToolbar.enabled) {
  749. return
  750. }
  751. const phase = currentVersion.phase
  752. const info = {
  753. phase,
  754. badgeInfo: splitTest.badgeInfo?.[phase],
  755. }
  756. if (phase === 'labs') {
  757. const variant = currentVersion.variants?.[0]
  758. info.labsDetails = {
  759. title: splitTest.labsTitle || '',
  760. description: splitTest.labsDescription || '',
  761. icon: splitTest.labsIcon || '',
  762. surveyLink: splitTest.badgeInfo?.labs?.url || '',
  763. successNotification: {
  764. content: splitTest.labsSuccessNotification?.content || '',
  765. buttonLabel: splitTest.labsSuccessNotification?.buttonLabel || '',
  766. buttonUrl: splitTest.labsSuccessNotification?.buttonUrl || '',
  767. },
  768. isFull: SplitTestUtils.isExperimentFull(variant),
  769. versionCreatedAt:
  770. currentVersion.createdAt instanceof Date
  771. ? currentVersion.createdAt.toISOString()
  772. : currentVersion.createdAt,
  773. }
  774. }
  775. if (Settings.devToolbar.enabled) {
  776. info.active = currentVersion.active
  777. info.variants = currentVersion.variants.map(variant => ({
  778. name: variant.name,
  779. rolloutPercent: variant.rolloutPercent,
  780. }))
  781. info.hasOverride = !!override
  782. }
  783. LocalsHelper.setSplitTestInfo(locals, splitTestName, info)
  784. } else if (Settings.devToolbar.enabled) {
  785. LocalsHelper.setSplitTestInfo(locals, splitTestName, {
  786. missing: true,
  787. })
  788. }
  789. }
  790. function _getNonSaasAssignment(splitTestName) {
  791. if (Settings.splitTestOverrides?.[splitTestName]) {
  792. return {
  793. variant: Settings.splitTestOverrides?.[splitTestName],
  794. metadata: {},
  795. }
  796. }
  797. return DEFAULT_ASSIGNMENT
  798. }
  799. async function _getSplitTest(name) {
  800. const splitTests = await SplitTestCache.get('')
  801. const splitTest = splitTests?.get(name)
  802. if (splitTest && !splitTest.archived) {
  803. return splitTest
  804. }
  805. }
  806. function _isSplitTest(featureFlag) {
  807. return SplitTestUtils.getCurrentVersion(featureFlag).analyticsEnabled
  808. }
  809. function _isGradualRollout(featureFlag) {
  810. return !SplitTestUtils.getCurrentVersion(featureFlag).analyticsEnabled
  811. }
  812. function _currentVersionHasUserLimit(featureFlag) {
  813. const currentVersion = SplitTestUtils.getCurrentVersion(featureFlag)
  814. return currentVersion.variants.some(
  815. v => v.userLimit && typeof v.userLimit === 'number'
  816. )
  817. }
  818. /**
  819. * Increment the user counter for a specific variant
  820. * @param {string} splitTestName - The name of the split test
  821. * @param {string} variantName - The name of the variant
  822. * @param {number} versionNumber - The version to update
  823. */
  824. async function _incrementVariantCounter(
  825. splitTestName,
  826. variantName,
  827. versionNumber
  828. ) {
  829. try {
  830. await SplitTest.updateOne(
  831. {
  832. name: splitTestName,
  833. 'versions.versionNumber': versionNumber,
  834. 'versions.variants.name': variantName,
  835. },
  836. {
  837. $inc: {
  838. 'versions.$.variants.$[variant].userCount': 1,
  839. },
  840. },
  841. {
  842. arrayFilters: [{ 'variant.name': variantName }],
  843. }
  844. ).exec()
  845. } catch (error) {
  846. logger.error(
  847. { err: error, splitTestName, variantName, versionNumber },
  848. 'Failed to increment variant counter'
  849. )
  850. }
  851. }
  852. /**
  853. * Atomically increment the labs variant counter only if below the user limit.
  854. * Returns true if a slot was claimed, false if the limit has been reached.
  855. * When there is no userLimit, enrollment is always allowed (returns true).
  856. * @param {string} splitTestName
  857. * @returns {Promise<boolean>}
  858. */
  859. async function incrementLabsVariantCounterIfBelowLimit(splitTestName) {
  860. const splitTest = await _getSplitTest(splitTestName)
  861. if (!splitTest) return false
  862. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  863. if (!currentVersion || currentVersion.phase !== LABS_PHASE) return false
  864. const variant = currentVersion.variants[0]
  865. if (!variant) return false
  866. if (!variant.userLimit || typeof variant.userLimit !== 'number') {
  867. return true
  868. }
  869. const result = await SplitTest.updateOne(
  870. {
  871. name: splitTestName,
  872. 'versions.versionNumber': currentVersion.versionNumber,
  873. },
  874. {
  875. $inc: {
  876. 'versions.$.variants.$[variant].userCount': 1,
  877. },
  878. },
  879. {
  880. arrayFilters: [
  881. {
  882. 'variant.name': variant.name,
  883. 'variant.userCount': { $not: { $gte: variant.userLimit } },
  884. },
  885. ],
  886. }
  887. ).exec()
  888. return result.modifiedCount > 0
  889. }
  890. /**
  891. * Decrement the user counter for a labs experiment when a user opts out.
  892. * This frees up a slot so another user can enroll.
  893. * @param {string} splitTestName
  894. */
  895. async function decrementLabsVariantCounter(splitTestName) {
  896. const splitTest = await _getSplitTest(splitTestName)
  897. if (!splitTest) return
  898. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  899. if (!currentVersion || currentVersion.phase !== LABS_PHASE) return
  900. const variant = currentVersion.variants[0]
  901. if (!variant?.userLimit || typeof variant.userLimit !== 'number') return
  902. if (!variant.userCount || variant.userCount <= 0) return
  903. try {
  904. const result = await SplitTest.updateOne(
  905. {
  906. name: splitTestName,
  907. 'versions.versionNumber': currentVersion.versionNumber,
  908. 'versions.variants.name': variant.name,
  909. },
  910. {
  911. $inc: {
  912. 'versions.$.variants.$[variant].userCount': -1,
  913. },
  914. },
  915. {
  916. arrayFilters: [{ 'variant.name': variant.name }],
  917. }
  918. ).exec()
  919. if (result.modifiedCount === 0) {
  920. logger.warn(
  921. { splitTestName },
  922. 'Labs variant counter decrement matched no documents'
  923. )
  924. }
  925. } catch (error) {
  926. logger.error(
  927. { err: error, splitTestName },
  928. 'Failed to decrement labs variant counter'
  929. )
  930. }
  931. }
  932. async function userMaintenanceOnLogin(user) {
  933. const splitTests = (await SplitTestCache.get('')).values()
  934. const toCleanup = {}
  935. for (const splitTest of splitTests) {
  936. if (splitTest.archived && user.splitTests?.[splitTest.name]) {
  937. toCleanup[`splitTests.${splitTest.name}`] = 1
  938. }
  939. }
  940. if (Object.keys(toCleanup).length > 0) {
  941. await UserUpdater.promises.updateUser(user._id, {
  942. $unset: toCleanup,
  943. })
  944. }
  945. }
  946. export default {
  947. getPercentile,
  948. getAssignment: callbackify(getAssignment),
  949. getAssignmentForUser: callbackify(getAssignmentForUser),
  950. featureFlagEnabled: callbackify(featureFlagEnabled),
  951. featureFlagEnabledForUser: callbackify(featureFlagEnabledForUser),
  952. getOneTimeAssignment: callbackify(getOneTimeAssignment),
  953. getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
  954. hasUserBeenAssignedToVariant: callbackify(hasUserBeenAssignedToVariant),
  955. setOverrideInSession,
  956. clearOverridesInSession,
  957. promises: {
  958. getAssignment,
  959. getAssignmentForUser,
  960. featureFlagEnabled,
  961. featureFlagEnabledForUser,
  962. getOneTimeAssignment,
  963. getActiveAssignmentsForUser,
  964. hasUserBeenAssignedToVariant,
  965. decrementLabsVariantCounter,
  966. incrementLabsVariantCounterIfBelowLimit,
  967. userMaintenanceOnLogin,
  968. },
  969. }