SplitTestHandler.mjs 35 KB

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