SplitTestHandler.mjs 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059
  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 UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.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 UserAnalyticsIdCache.getWithMetrics(
  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. isFull: SplitTestUtils.isExperimentFull(variant),
  764. versionCreatedAt:
  765. currentVersion.createdAt instanceof Date
  766. ? currentVersion.createdAt.toISOString()
  767. : currentVersion.createdAt,
  768. }
  769. }
  770. if (Settings.devToolbar.enabled) {
  771. info.active = currentVersion.active
  772. info.variants = currentVersion.variants.map(variant => ({
  773. name: variant.name,
  774. rolloutPercent: variant.rolloutPercent,
  775. }))
  776. info.hasOverride = !!override
  777. }
  778. LocalsHelper.setSplitTestInfo(locals, splitTestName, info)
  779. } else if (Settings.devToolbar.enabled) {
  780. LocalsHelper.setSplitTestInfo(locals, splitTestName, {
  781. missing: true,
  782. })
  783. }
  784. }
  785. function _getNonSaasAssignment(splitTestName) {
  786. if (Settings.splitTestOverrides?.[splitTestName]) {
  787. return {
  788. variant: Settings.splitTestOverrides?.[splitTestName],
  789. metadata: {},
  790. }
  791. }
  792. return DEFAULT_ASSIGNMENT
  793. }
  794. async function _getSplitTest(name) {
  795. const splitTests = await SplitTestCache.get('')
  796. const splitTest = splitTests?.get(name)
  797. if (splitTest && !splitTest.archived) {
  798. return splitTest
  799. }
  800. }
  801. function _isSplitTest(featureFlag) {
  802. return SplitTestUtils.getCurrentVersion(featureFlag).analyticsEnabled
  803. }
  804. function _isGradualRollout(featureFlag) {
  805. return !SplitTestUtils.getCurrentVersion(featureFlag).analyticsEnabled
  806. }
  807. function _currentVersionHasUserLimit(featureFlag) {
  808. const currentVersion = SplitTestUtils.getCurrentVersion(featureFlag)
  809. return currentVersion.variants.some(
  810. v => v.userLimit && typeof v.userLimit === 'number'
  811. )
  812. }
  813. /**
  814. * Increment the user counter for a specific variant
  815. * @param {string} splitTestName - The name of the split test
  816. * @param {string} variantName - The name of the variant
  817. * @param {number} versionNumber - The version to update
  818. */
  819. async function _incrementVariantCounter(
  820. splitTestName,
  821. variantName,
  822. versionNumber
  823. ) {
  824. try {
  825. await SplitTest.updateOne(
  826. {
  827. name: splitTestName,
  828. 'versions.versionNumber': versionNumber,
  829. 'versions.variants.name': variantName,
  830. },
  831. {
  832. $inc: {
  833. 'versions.$.variants.$[variant].userCount': 1,
  834. },
  835. },
  836. {
  837. arrayFilters: [{ 'variant.name': variantName }],
  838. }
  839. ).exec()
  840. } catch (error) {
  841. logger.error(
  842. { err: error, splitTestName, variantName, versionNumber },
  843. 'Failed to increment variant counter'
  844. )
  845. }
  846. }
  847. /**
  848. * Atomically increment the labs variant counter only if below the user limit.
  849. * Returns true if a slot was claimed, false if the limit has been reached.
  850. * When there is no userLimit, enrollment is always allowed (returns true).
  851. * @param {string} splitTestName
  852. * @returns {Promise<boolean>}
  853. */
  854. async function incrementLabsVariantCounterIfBelowLimit(splitTestName) {
  855. const splitTest = await _getSplitTest(splitTestName)
  856. if (!splitTest) return false
  857. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  858. if (!currentVersion || currentVersion.phase !== LABS_PHASE) return false
  859. const variant = currentVersion.variants[0]
  860. if (!variant) return false
  861. if (!variant.userLimit || typeof variant.userLimit !== 'number') {
  862. return true
  863. }
  864. const result = await SplitTest.updateOne(
  865. {
  866. name: splitTestName,
  867. 'versions.versionNumber': currentVersion.versionNumber,
  868. },
  869. {
  870. $inc: {
  871. 'versions.$.variants.$[variant].userCount': 1,
  872. },
  873. },
  874. {
  875. arrayFilters: [
  876. {
  877. 'variant.name': variant.name,
  878. 'variant.userCount': { $not: { $gte: variant.userLimit } },
  879. },
  880. ],
  881. }
  882. ).exec()
  883. return result.modifiedCount > 0
  884. }
  885. /**
  886. * Decrement the user counter for a labs experiment when a user opts out.
  887. * This frees up a slot so another user can enroll.
  888. * @param {string} splitTestName
  889. */
  890. async function decrementLabsVariantCounter(splitTestName) {
  891. const splitTest = await _getSplitTest(splitTestName)
  892. if (!splitTest) return
  893. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  894. if (!currentVersion || currentVersion.phase !== LABS_PHASE) return
  895. const variant = currentVersion.variants[0]
  896. if (!variant?.userLimit || typeof variant.userLimit !== 'number') return
  897. if (!variant.userCount || variant.userCount <= 0) return
  898. try {
  899. const result = await SplitTest.updateOne(
  900. {
  901. name: splitTestName,
  902. 'versions.versionNumber': currentVersion.versionNumber,
  903. 'versions.variants.name': variant.name,
  904. },
  905. {
  906. $inc: {
  907. 'versions.$.variants.$[variant].userCount': -1,
  908. },
  909. },
  910. {
  911. arrayFilters: [{ 'variant.name': variant.name }],
  912. }
  913. ).exec()
  914. if (result.modifiedCount === 0) {
  915. logger.warn(
  916. { splitTestName },
  917. 'Labs variant counter decrement matched no documents'
  918. )
  919. }
  920. } catch (error) {
  921. logger.error(
  922. { err: error, splitTestName },
  923. 'Failed to decrement labs variant counter'
  924. )
  925. }
  926. }
  927. async function userMaintenanceOnLogin(user) {
  928. const splitTests = (await SplitTestCache.get('')).values()
  929. const toCleanup = {}
  930. for (const splitTest of splitTests) {
  931. if (splitTest.archived && user.splitTests?.[splitTest.name]) {
  932. toCleanup[`splitTests.${splitTest.name}`] = 1
  933. }
  934. }
  935. if (Object.keys(toCleanup).length > 0) {
  936. await UserUpdater.promises.updateUser(user._id, {
  937. $unset: toCleanup,
  938. })
  939. }
  940. }
  941. export default {
  942. getPercentile,
  943. getAssignment: callbackify(getAssignment),
  944. getAssignmentForUser: callbackify(getAssignmentForUser),
  945. featureFlagEnabled: callbackify(featureFlagEnabled),
  946. featureFlagEnabledForUser: callbackify(featureFlagEnabledForUser),
  947. getOneTimeAssignment: callbackify(getOneTimeAssignment),
  948. getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
  949. hasUserBeenAssignedToVariant: callbackify(hasUserBeenAssignedToVariant),
  950. setOverrideInSession,
  951. clearOverridesInSession,
  952. promises: {
  953. getAssignment,
  954. getAssignmentForUser,
  955. featureFlagEnabled,
  956. featureFlagEnabledForUser,
  957. getOneTimeAssignment,
  958. getActiveAssignmentsForUser,
  959. hasUserBeenAssignedToVariant,
  960. decrementLabsVariantCounter,
  961. incrementLabsVariantCounterIfBelowLimit,
  962. userMaintenanceOnLogin,
  963. },
  964. }