SplitTestHandler.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. const Metrics = require('@overleaf/metrics')
  2. const UserUpdater = require('../User/UserUpdater')
  3. const AnalyticsManager = require('../Analytics/AnalyticsManager')
  4. const LocalsHelper = require('./LocalsHelper')
  5. const crypto = require('crypto')
  6. const _ = require('lodash')
  7. const { callbackify } = require('util')
  8. const SplitTestCache = require('./SplitTestCache')
  9. const { SplitTest } = require('../../models/SplitTest')
  10. const UserAnalyticsIdCache = require('../Analytics/UserAnalyticsIdCache')
  11. const Features = require('../../infrastructure/Features')
  12. const SplitTestUtils = require('./SplitTestUtils')
  13. const Settings = require('@overleaf/settings')
  14. const SessionManager = require('../Authentication/SessionManager')
  15. const logger = require('@overleaf/logger')
  16. const SplitTestSessionHandler = require('./SplitTestSessionHandler')
  17. const SplitTestUserGetter = require('./SplitTestUserGetter')
  18. /**
  19. * @import { Assignment } from "./types"
  20. */
  21. const DEFAULT_VARIANT = 'default'
  22. const ALPHA_PHASE = 'alpha'
  23. const BETA_PHASE = 'beta'
  24. const RELEASE_PHASE = 'release'
  25. const DEFAULT_ASSIGNMENT = {
  26. variant: DEFAULT_VARIANT,
  27. metadata: {},
  28. }
  29. /**
  30. * Get the assignment of a user to a split test and store it in the response locals context
  31. *
  32. * @example
  33. * // Assign user and record an event
  34. *
  35. * const assignment = await SplitTestHandler.getAssignment(req, res, 'example-project')
  36. * if (assignment.variant === 'awesome-new-version') {
  37. * // execute my awesome change
  38. * }
  39. * else {
  40. * // execute the default behaviour (control group)
  41. * }
  42. *
  43. * @param req the request
  44. * @param res the Express response object
  45. * @param splitTestName the unique name of the split test
  46. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  47. * @returns {Promise<Assignment>}
  48. */
  49. async function getAssignment(req, res, splitTestName, { sync = false } = {}) {
  50. const query = req.query || {}
  51. let assignment
  52. try {
  53. if (!Features.hasFeature('saas')) {
  54. assignment = _getNonSaasAssignment(splitTestName)
  55. } else {
  56. await _loadSplitTestInfoInLocals(res.locals, splitTestName, req.session)
  57. // Check the query string for an override, ignoring an invalid value
  58. const queryVariant = query[splitTestName]
  59. if (queryVariant) {
  60. const variants = await _getVariantNames(splitTestName)
  61. if (variants.includes(queryVariant)) {
  62. assignment = {
  63. variant: queryVariant,
  64. metadata: {},
  65. }
  66. }
  67. }
  68. if (!assignment) {
  69. const { userId, analyticsId } = AnalyticsManager.getIdsFromSession(
  70. req.session
  71. )
  72. assignment = await _getAssignment(splitTestName, {
  73. analyticsId,
  74. userId,
  75. session: req.session,
  76. sync,
  77. })
  78. SplitTestSessionHandler.collectSessionStats(req.session)
  79. }
  80. }
  81. } catch (error) {
  82. logger.error({ err: error }, 'Failed to get split test assignment')
  83. assignment = DEFAULT_ASSIGNMENT
  84. }
  85. LocalsHelper.setSplitTestVariant(
  86. res.locals,
  87. splitTestName,
  88. assignment.variant
  89. )
  90. return assignment
  91. }
  92. /**
  93. * Get the assignment of a user to a split test by their user ID.
  94. *
  95. * Warning: this does not support query parameters override, nor makes the assignment and split test info available to
  96. * the frontend through locals. Wherever possible, `getAssignment` should be used instead.
  97. *
  98. * @param userId the user ID
  99. * @param splitTestName the unique name of the split test
  100. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  101. * @returns {Promise<Assignment>}
  102. */
  103. async function getAssignmentForUser(
  104. userId,
  105. splitTestName,
  106. { sync = false } = {}
  107. ) {
  108. try {
  109. if (!Features.hasFeature('saas')) {
  110. return _getNonSaasAssignment(splitTestName)
  111. }
  112. const analyticsId = await UserAnalyticsIdCache.get(userId)
  113. return _getAssignment(splitTestName, { analyticsId, userId, sync })
  114. } catch (error) {
  115. logger.error({ err: error }, 'Failed to get split test assignment for user')
  116. return DEFAULT_ASSIGNMENT
  117. }
  118. }
  119. /**
  120. * Returns true if user has already been explicitly assigned to a variant.
  121. * This will be false if the user **would** be assigned when calling getAssignment but hasn't yet.
  122. *
  123. * @param req express request
  124. * @param {string} userId the user ID
  125. * @param {string} splitTestName the unique name of the split test
  126. * @param {string} variant variant name to check
  127. * @param {boolean} ignoreVersion users explicitly assigned to a previous version should be treated as if assigned to latest version
  128. */
  129. async function hasUserBeenAssignedToVariant(
  130. req,
  131. userId,
  132. splitTestName,
  133. variant,
  134. ignoreVersion = false
  135. ) {
  136. try {
  137. const { session = {}, query = {} } = req
  138. const splitTest = await _getSplitTest(splitTestName)
  139. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  140. if (!userId || !currentVersion?.active) {
  141. return false
  142. }
  143. // Check the query string for an override, ignoring an invalid value
  144. const queryVariant = query[splitTestName]
  145. if (queryVariant === variant) {
  146. const variants = await _getVariantNames(splitTestName)
  147. if (variants.includes(queryVariant)) {
  148. return true
  149. }
  150. }
  151. // Allow dev toolbar and session cache to override assignment from DB
  152. if (Settings.devToolbar.enabled) {
  153. const override = session?.splitTestOverrides?.[splitTestName]
  154. if (override === variant) {
  155. return true
  156. }
  157. }
  158. const canUseSessionCache = session && SessionManager.isUserLoggedIn(session)
  159. if (canUseSessionCache) {
  160. const cachedVariant = SplitTestSessionHandler.getCachedVariant(
  161. session,
  162. splitTestName,
  163. currentVersion
  164. )
  165. if (cachedVariant === variant) {
  166. return true
  167. }
  168. }
  169. // get variant from db, including explicit assignments from previous versions if requested
  170. const assignments = await getActiveAssignmentsForUser(
  171. userId,
  172. true,
  173. ignoreVersion
  174. )
  175. const testAssignment = assignments[splitTestName]
  176. if (!testAssignment || !testAssignment.assignedAt) {
  177. return false
  178. }
  179. // if variant matches and we can use cache, we should persist it in cache
  180. if (testAssignment.variantName === variant && testAssignment.assignedAt) {
  181. if (canUseSessionCache) {
  182. SplitTestSessionHandler.setVariantInCache({
  183. session,
  184. splitTestName,
  185. currentVersion,
  186. selectedVariantName: variant,
  187. activeForUser: true,
  188. })
  189. }
  190. return true
  191. }
  192. } catch (error) {
  193. logger.error({ err: error }, 'Failed to get split test assignment for user')
  194. return false
  195. }
  196. }
  197. /**
  198. * Get a mapping of the active split test assignments for the given user
  199. */
  200. async function getActiveAssignmentsForUser(
  201. userId,
  202. removeArchived = false,
  203. ignoreVersion = false
  204. ) {
  205. if (!Features.hasFeature('saas')) {
  206. return {}
  207. }
  208. const user = await SplitTestUserGetter.promises.getUser(userId)
  209. if (user == null) {
  210. return {}
  211. }
  212. const splitTests = await SplitTest.find({
  213. $where: 'this.versions[this.versions.length - 1].active',
  214. ...(removeArchived && { archived: { $ne: true } }),
  215. }).exec()
  216. const assignments = {}
  217. for (const splitTest of splitTests) {
  218. const { activeForUser, selectedVariantName, phase, versionNumber } =
  219. await _getAssignmentMetadata(user.analyticsId, user, splitTest)
  220. if (activeForUser) {
  221. const assignment = {
  222. variantName: selectedVariantName,
  223. versionNumber,
  224. phase,
  225. }
  226. const userAssignments = user.splitTests?.[splitTest.name]
  227. if (Array.isArray(userAssignments)) {
  228. let userAssignment
  229. if (!ignoreVersion) {
  230. userAssignment = userAssignments.find(
  231. x => x.versionNumber === versionNumber
  232. )
  233. } else {
  234. userAssignment = userAssignments[0]
  235. }
  236. if (userAssignment) {
  237. assignment.assignedAt = userAssignment.assignedAt
  238. }
  239. }
  240. assignments[splitTest.name] = assignment
  241. }
  242. }
  243. return assignments
  244. }
  245. /**
  246. * Performs a one-time assignment that is not recorded nor reproducible.
  247. * To be used only in cases where we need random assignments that are independent of a user or session.
  248. * If the test is in alpha or beta phase, always returns the default variant.
  249. * @param splitTestName
  250. * @returns {Promise<Assignment>}
  251. */
  252. async function getOneTimeAssignment(splitTestName) {
  253. try {
  254. if (!Features.hasFeature('saas')) {
  255. return _getNonSaasAssignment(splitTestName)
  256. }
  257. const splitTest = await _getSplitTest(splitTestName)
  258. if (!splitTest) {
  259. return DEFAULT_ASSIGNMENT
  260. }
  261. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  262. if (currentVersion.phase !== RELEASE_PHASE) {
  263. return DEFAULT_ASSIGNMENT
  264. }
  265. const randomUUID = crypto.randomUUID()
  266. const { selectedVariantName } = await _getAssignmentMetadata(
  267. randomUUID,
  268. undefined,
  269. splitTest
  270. )
  271. return _makeAssignment({
  272. variant: selectedVariantName,
  273. currentVersion,
  274. isFirstNonDefaultAssignment:
  275. selectedVariantName !== DEFAULT_VARIANT && _isSplitTest(splitTest),
  276. })
  277. } catch (error) {
  278. logger.error({ err: error }, 'Failed to get one time split test assignment')
  279. return DEFAULT_ASSIGNMENT
  280. }
  281. }
  282. /**
  283. * Returns an array of valid variant names for the given split test, including default
  284. *
  285. * @param splitTestName
  286. * @returns {Promise<string[]>}
  287. * @private
  288. */
  289. async function _getVariantNames(splitTestName) {
  290. const splitTest = await _getSplitTest(splitTestName)
  291. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  292. if (currentVersion?.active) {
  293. return currentVersion.variants.map(v => v.name).concat([DEFAULT_VARIANT])
  294. } else {
  295. return [DEFAULT_VARIANT]
  296. }
  297. }
  298. async function _getAssignment(
  299. splitTestName,
  300. { analyticsId, user, userId, session, sync }
  301. ) {
  302. if (!analyticsId && !userId) {
  303. return DEFAULT_ASSIGNMENT
  304. }
  305. const splitTest = await _getSplitTest(splitTestName)
  306. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  307. if (Settings.devToolbar.enabled) {
  308. const override = session?.splitTestOverrides?.[splitTestName]
  309. if (override) {
  310. return _makeAssignment({ variant: override, currentVersion })
  311. }
  312. }
  313. if (!currentVersion?.active) {
  314. return DEFAULT_ASSIGNMENT
  315. }
  316. // Do not cache assignments for anonymous users. All the context for their assignments is in the session:
  317. // They cannot be part of the alpha or beta program, and they will use their analyticsId for assignments.
  318. const canUseSessionCache = session && SessionManager.isUserLoggedIn(session)
  319. if (session && !canUseSessionCache) {
  320. // Purge the existing cache
  321. delete session.cachedSplitTestAssignments
  322. }
  323. if (canUseSessionCache) {
  324. const cachedVariant = SplitTestSessionHandler.getCachedVariant(
  325. session,
  326. splitTest.name,
  327. currentVersion
  328. )
  329. if (cachedVariant) {
  330. Metrics.inc('split_test_get_assignment_source', 1, { status: 'cache' })
  331. if (
  332. cachedVariant ===
  333. SplitTestSessionHandler.CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER
  334. ) {
  335. return DEFAULT_ASSIGNMENT
  336. } else {
  337. return _makeAssignment({
  338. variant: cachedVariant,
  339. currentVersion,
  340. isFirstNonDefaultAssignment: false,
  341. })
  342. }
  343. }
  344. }
  345. if (user) {
  346. Metrics.inc('split_test_get_assignment_source', 1, { status: 'provided' })
  347. } else if (userId) {
  348. Metrics.inc('split_test_get_assignment_source', 1, { status: 'mongo' })
  349. } else {
  350. Metrics.inc('split_test_get_assignment_source', 1, { status: 'none' })
  351. }
  352. user =
  353. user ||
  354. (userId &&
  355. (await SplitTestUserGetter.promises.getUser(userId, splitTestName)))
  356. const metadata = await _getAssignmentMetadata(analyticsId, user, splitTest)
  357. const { activeForUser, selectedVariantName, phase, versionNumber } = metadata
  358. if (canUseSessionCache) {
  359. SplitTestSessionHandler.setVariantInCache({
  360. session,
  361. splitTestName,
  362. currentVersion,
  363. selectedVariantName,
  364. activeForUser,
  365. })
  366. }
  367. if (activeForUser) {
  368. if (_isSplitTest(splitTest)) {
  369. // if the user is logged in, persist the assignment
  370. if (userId) {
  371. const assignmentData = {
  372. user,
  373. userId,
  374. splitTestName,
  375. phase,
  376. versionNumber,
  377. variantName: selectedVariantName,
  378. }
  379. if (sync === true) {
  380. await _recordAssignment(assignmentData)
  381. } else {
  382. _recordAssignment(assignmentData).catch(err => {
  383. logger.warn(
  384. {
  385. err,
  386. userId,
  387. splitTestName,
  388. phase,
  389. versionNumber,
  390. variantName: selectedVariantName,
  391. },
  392. 'failed to record split test assignment'
  393. )
  394. })
  395. }
  396. }
  397. // otherwise this is an anonymous user, we store assignments in session to persist them on registration
  398. else {
  399. await SplitTestSessionHandler.promises.appendAssignment(session, {
  400. splitTestId: splitTest._id,
  401. splitTestName,
  402. phase,
  403. versionNumber,
  404. variantName: selectedVariantName,
  405. assignedAt: new Date(),
  406. })
  407. }
  408. const effectiveAnalyticsId = user?.analyticsId || analyticsId || userId
  409. AnalyticsManager.setUserPropertyForAnalyticsId(
  410. effectiveAnalyticsId,
  411. `split-test-${splitTestName}-${versionNumber}`,
  412. selectedVariantName
  413. ).catch(err => {
  414. logger.warn(
  415. {
  416. err,
  417. analyticsId: effectiveAnalyticsId,
  418. splitTest: splitTestName,
  419. versionNumber,
  420. variant: selectedVariantName,
  421. },
  422. 'failed to set user property for analytics id'
  423. )
  424. })
  425. }
  426. let isFirstNonDefaultAssignment
  427. if (userId) {
  428. isFirstNonDefaultAssignment = metadata.isFirstNonDefaultAssignment
  429. } else {
  430. const assignments =
  431. await SplitTestSessionHandler.promises.getAssignments(session)
  432. isFirstNonDefaultAssignment = !assignments?.[splitTestName]
  433. }
  434. return _makeAssignment({
  435. variant: selectedVariantName,
  436. currentVersion,
  437. isFirstNonDefaultAssignment,
  438. })
  439. }
  440. return DEFAULT_ASSIGNMENT
  441. }
  442. async function _getAssignmentMetadata(analyticsId, user, splitTest) {
  443. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  444. const versionNumber = currentVersion.versionNumber
  445. const phase = currentVersion.phase
  446. // For continuity on phase rollout for gradual rollouts, we keep all users from the previous phase enrolled to the variant.
  447. // In beta, all alpha users are cohorted to the variant, and the same in release phase all alpha & beta users.
  448. if (
  449. _isGradualRollout(splitTest) &&
  450. ((phase === BETA_PHASE && user?.alphaProgram) ||
  451. (phase === RELEASE_PHASE && (user?.alphaProgram || user?.betaProgram)))
  452. ) {
  453. return {
  454. activeForUser: true,
  455. selectedVariantName: currentVersion.variants[0].name,
  456. phase,
  457. versionNumber,
  458. isFirstNonDefaultAssignment: false,
  459. }
  460. }
  461. if (
  462. (phase === ALPHA_PHASE && !user?.alphaProgram) ||
  463. (phase === BETA_PHASE && !user?.betaProgram)
  464. ) {
  465. return {
  466. activeForUser: false,
  467. }
  468. }
  469. const userId = user?._id.toString()
  470. const percentile = getPercentile(analyticsId || userId, splitTest.name, phase)
  471. const selectedVariantName =
  472. _getVariantFromPercentile(currentVersion.variants, percentile) ||
  473. DEFAULT_VARIANT
  474. return {
  475. activeForUser: true,
  476. selectedVariantName,
  477. phase,
  478. versionNumber,
  479. isFirstNonDefaultAssignment:
  480. selectedVariantName !== DEFAULT_VARIANT &&
  481. _isSplitTest(splitTest) &&
  482. (!Array.isArray(user?.splitTests?.[splitTest.name]) ||
  483. !user?.splitTests?.[splitTest.name]?.some(
  484. assignment => assignment.variantName !== DEFAULT_VARIANT
  485. )),
  486. }
  487. }
  488. function getPercentile(analyticsId, splitTestName, splitTestPhase) {
  489. const hash = crypto
  490. .createHash('md5')
  491. .update(analyticsId + splitTestName + splitTestPhase)
  492. .digest('hex')
  493. const hashPrefix = hash.substr(0, 8)
  494. return Math.floor(
  495. ((parseInt(hashPrefix, 16) % 0xffffffff) / 0xffffffff) * 100
  496. )
  497. }
  498. function setOverrideInSession(session, splitTestName, variantName) {
  499. if (!Settings.devToolbar.enabled) {
  500. return
  501. }
  502. if (!session.splitTestOverrides) {
  503. session.splitTestOverrides = {}
  504. }
  505. session.splitTestOverrides[splitTestName] = variantName
  506. }
  507. function clearOverridesInSession(session) {
  508. delete session.splitTestOverrides
  509. }
  510. function _getVariantFromPercentile(variants, percentile) {
  511. for (const variant of variants) {
  512. for (const stripe of variant.rolloutStripes) {
  513. if (percentile >= stripe.start && percentile < stripe.end) {
  514. return variant.name
  515. }
  516. }
  517. }
  518. }
  519. async function _recordAssignment({
  520. user,
  521. userId,
  522. splitTestName,
  523. phase,
  524. versionNumber,
  525. variantName,
  526. }) {
  527. const persistedAssignment = {
  528. variantName,
  529. versionNumber,
  530. phase,
  531. assignedAt: new Date(),
  532. }
  533. user =
  534. user || (await SplitTestUserGetter.promises.getUser(userId, splitTestName))
  535. if (user) {
  536. const assignedSplitTests = user.splitTests || []
  537. const assignmentLog = assignedSplitTests[splitTestName] || []
  538. const existingAssignment = _.find(assignmentLog, { versionNumber })
  539. if (!existingAssignment) {
  540. await UserUpdater.promises.updateUser(userId, {
  541. $addToSet: {
  542. [`splitTests.${splitTestName}`]: persistedAssignment,
  543. },
  544. })
  545. }
  546. }
  547. }
  548. function _makeAssignment({
  549. variant,
  550. currentVersion,
  551. isFirstNonDefaultAssignment,
  552. }) {
  553. return {
  554. variant,
  555. metadata: {
  556. phase: currentVersion.phase,
  557. versionNumber: currentVersion.versionNumber,
  558. isFirstNonDefaultAssignment,
  559. },
  560. }
  561. }
  562. async function _loadSplitTestInfoInLocals(locals, splitTestName, session) {
  563. const splitTest = await _getSplitTest(splitTestName)
  564. if (splitTest) {
  565. const override = session?.splitTestOverrides?.[splitTestName]
  566. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  567. if (!currentVersion.active && !Settings.devToolbar.enabled) {
  568. return
  569. }
  570. const phase = currentVersion.phase
  571. const info = {
  572. phase,
  573. badgeInfo: splitTest.badgeInfo?.[phase],
  574. }
  575. if (Settings.devToolbar.enabled) {
  576. info.active = currentVersion.active
  577. info.variants = currentVersion.variants.map(variant => ({
  578. name: variant.name,
  579. rolloutPercent: variant.rolloutPercent,
  580. }))
  581. info.hasOverride = !!override
  582. }
  583. LocalsHelper.setSplitTestInfo(locals, splitTestName, info)
  584. } else if (Settings.devToolbar.enabled) {
  585. LocalsHelper.setSplitTestInfo(locals, splitTestName, {
  586. missing: true,
  587. })
  588. }
  589. }
  590. function _getNonSaasAssignment(splitTestName) {
  591. if (Settings.splitTestOverrides?.[splitTestName]) {
  592. return {
  593. variant: Settings.splitTestOverrides?.[splitTestName],
  594. metadata: {},
  595. }
  596. }
  597. return DEFAULT_ASSIGNMENT
  598. }
  599. async function _getSplitTest(name) {
  600. const splitTests = await SplitTestCache.get('')
  601. const splitTest = splitTests?.get(name)
  602. if (splitTest && !splitTest.archived) {
  603. return splitTest
  604. }
  605. }
  606. function _isSplitTest(featureFlag) {
  607. return SplitTestUtils.getCurrentVersion(featureFlag).analyticsEnabled
  608. }
  609. function _isGradualRollout(featureFlag) {
  610. return !SplitTestUtils.getCurrentVersion(featureFlag).analyticsEnabled
  611. }
  612. module.exports = {
  613. getPercentile,
  614. getAssignment: callbackify(getAssignment),
  615. getAssignmentForUser: callbackify(getAssignmentForUser),
  616. getOneTimeAssignment: callbackify(getOneTimeAssignment),
  617. getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
  618. hasUserBeenAssignedToVariant: callbackify(hasUserBeenAssignedToVariant),
  619. setOverrideInSession,
  620. clearOverridesInSession,
  621. promises: {
  622. getAssignment,
  623. getAssignmentForUser,
  624. getOneTimeAssignment,
  625. getActiveAssignmentsForUser,
  626. hasUserBeenAssignedToVariant,
  627. },
  628. }