SplitTestHandler.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  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. * Get the assignment of a user to a split test by their pre-fetched mongo doc.
  121. *
  122. * Warning: this does not support query parameters override, nor makes the assignment and split test info available to
  123. * the frontend through locals. Wherever possible, `getAssignment` should be used instead.
  124. *
  125. * @param user the user
  126. * @param splitTestName the unique name of the split test
  127. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  128. * @returns {Promise<Assignment>}
  129. */
  130. async function getAssignmentForMongoUser(
  131. user,
  132. splitTestName,
  133. { sync = false } = {}
  134. ) {
  135. try {
  136. if (!Features.hasFeature('saas')) {
  137. return _getNonSaasAssignment(splitTestName)
  138. }
  139. return _getAssignment(splitTestName, {
  140. analyticsId: await UserAnalyticsIdCache.get(user._id),
  141. sync,
  142. user,
  143. userId: user._id.toString(),
  144. })
  145. } catch (error) {
  146. logger.error(
  147. { err: error },
  148. 'Failed to get split test assignment for mongo user'
  149. )
  150. return DEFAULT_ASSIGNMENT
  151. }
  152. }
  153. /**
  154. * Get a mapping of the active split test assignments for the given user
  155. */
  156. async function getActiveAssignmentsForUser(userId, removeArchived = false) {
  157. if (!Features.hasFeature('saas')) {
  158. return {}
  159. }
  160. const user = await SplitTestUserGetter.promises.getUser(userId)
  161. if (user == null) {
  162. return {}
  163. }
  164. const splitTests = await SplitTest.find({
  165. $where: 'this.versions[this.versions.length - 1].active',
  166. ...(removeArchived && { archived: { $ne: true } }),
  167. }).exec()
  168. const assignments = {}
  169. for (const splitTest of splitTests) {
  170. const { activeForUser, selectedVariantName, phase, versionNumber } =
  171. await _getAssignmentMetadata(user.analyticsId, user, splitTest)
  172. if (activeForUser) {
  173. const assignment = {
  174. variantName: selectedVariantName,
  175. versionNumber,
  176. phase,
  177. }
  178. const userAssignments = user.splitTests?.[splitTest.name]
  179. if (Array.isArray(userAssignments)) {
  180. const userAssignment = userAssignments.find(
  181. x => x.versionNumber === versionNumber
  182. )
  183. if (userAssignment) {
  184. assignment.assignedAt = userAssignment.assignedAt
  185. }
  186. }
  187. assignments[splitTest.name] = assignment
  188. }
  189. }
  190. return assignments
  191. }
  192. /**
  193. * Performs a one-time assignment that is not recorded nor reproducible.
  194. * To be used only in cases where we need random assignments that are independent of a user or session.
  195. * If the test is in alpha or beta phase, always returns the default variant.
  196. * @param splitTestName
  197. * @returns {Promise<Assignment>}
  198. */
  199. async function getOneTimeAssignment(splitTestName) {
  200. try {
  201. if (!Features.hasFeature('saas')) {
  202. return _getNonSaasAssignment(splitTestName)
  203. }
  204. const splitTest = await _getSplitTest(splitTestName)
  205. if (!splitTest) {
  206. return DEFAULT_ASSIGNMENT
  207. }
  208. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  209. if (currentVersion.phase !== RELEASE_PHASE) {
  210. return DEFAULT_ASSIGNMENT
  211. }
  212. const randomUUID = crypto.randomUUID()
  213. const { selectedVariantName } = await _getAssignmentMetadata(
  214. randomUUID,
  215. undefined,
  216. splitTest
  217. )
  218. return _makeAssignment({
  219. variant: selectedVariantName,
  220. currentVersion,
  221. isFirstNonDefaultAssignment:
  222. selectedVariantName !== DEFAULT_VARIANT &&
  223. currentVersion.analyticsEnabled,
  224. })
  225. } catch (error) {
  226. logger.error({ err: error }, 'Failed to get one time split test assignment')
  227. return DEFAULT_ASSIGNMENT
  228. }
  229. }
  230. /**
  231. * Returns an array of valid variant names for the given split test, including default
  232. *
  233. * @param splitTestName
  234. * @returns {Promise<string[]>}
  235. * @private
  236. */
  237. async function _getVariantNames(splitTestName) {
  238. const splitTest = await _getSplitTest(splitTestName)
  239. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  240. if (currentVersion?.active) {
  241. return currentVersion.variants.map(v => v.name).concat([DEFAULT_VARIANT])
  242. } else {
  243. return [DEFAULT_VARIANT]
  244. }
  245. }
  246. async function _getAssignment(
  247. splitTestName,
  248. { analyticsId, user, userId, session, sync }
  249. ) {
  250. if (!analyticsId && !userId) {
  251. return DEFAULT_ASSIGNMENT
  252. }
  253. const splitTest = await _getSplitTest(splitTestName)
  254. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  255. if (Settings.devToolbar.enabled) {
  256. const override = session?.splitTestOverrides?.[splitTestName]
  257. if (override) {
  258. return _makeAssignment({ variant: override, currentVersion })
  259. }
  260. }
  261. if (!currentVersion?.active) {
  262. return DEFAULT_ASSIGNMENT
  263. }
  264. // Do not cache assignments for anonymous users. All the context for their assignments is in the session:
  265. // They cannot be part of the alpha or beta program, and they will use their analyticsId for assignments.
  266. const canUseSessionCache = session && SessionManager.isUserLoggedIn(session)
  267. if (session && !canUseSessionCache) {
  268. // Purge the existing cache
  269. delete session.cachedSplitTestAssignments
  270. }
  271. if (canUseSessionCache) {
  272. const cachedVariant = SplitTestSessionHandler.getCachedVariant(
  273. session,
  274. splitTest.name,
  275. currentVersion
  276. )
  277. if (cachedVariant) {
  278. Metrics.inc('split_test_get_assignment_source', 1, { status: 'cache' })
  279. if (
  280. cachedVariant ===
  281. SplitTestSessionHandler.CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER
  282. ) {
  283. return DEFAULT_ASSIGNMENT
  284. } else {
  285. return _makeAssignment({
  286. variant: cachedVariant,
  287. currentVersion,
  288. isFirstNonDefaultAssignment: false,
  289. })
  290. }
  291. }
  292. }
  293. if (user) {
  294. Metrics.inc('split_test_get_assignment_source', 1, { status: 'provided' })
  295. } else if (userId) {
  296. Metrics.inc('split_test_get_assignment_source', 1, { status: 'mongo' })
  297. } else {
  298. Metrics.inc('split_test_get_assignment_source', 1, { status: 'none' })
  299. }
  300. user =
  301. user ||
  302. (userId &&
  303. (await SplitTestUserGetter.promises.getUser(userId, splitTestName)))
  304. const metadata = await _getAssignmentMetadata(analyticsId, user, splitTest)
  305. const { activeForUser, selectedVariantName, phase, versionNumber } = metadata
  306. if (canUseSessionCache) {
  307. SplitTestSessionHandler.setVariantInCache({
  308. session,
  309. splitTestName,
  310. currentVersion,
  311. selectedVariantName,
  312. activeForUser,
  313. })
  314. }
  315. if (activeForUser) {
  316. if (currentVersion.analyticsEnabled) {
  317. // if the user is logged in, persist the assignment
  318. if (userId) {
  319. const assignmentData = {
  320. user,
  321. userId,
  322. splitTestName,
  323. phase,
  324. versionNumber,
  325. variantName: selectedVariantName,
  326. }
  327. if (sync === true) {
  328. await _recordAssignment(assignmentData)
  329. } else {
  330. _recordAssignment(assignmentData).catch(err => {
  331. logger.warn(
  332. {
  333. err,
  334. userId,
  335. splitTestName,
  336. phase,
  337. versionNumber,
  338. variantName: selectedVariantName,
  339. },
  340. 'failed to record split test assignment'
  341. )
  342. })
  343. }
  344. }
  345. // otherwise this is an anonymous user, we store assignments in session to persist them on registration
  346. else {
  347. await SplitTestSessionHandler.promises.appendAssignment(session, {
  348. splitTestId: splitTest._id,
  349. splitTestName,
  350. phase,
  351. versionNumber,
  352. variantName: selectedVariantName,
  353. assignedAt: new Date(),
  354. })
  355. }
  356. const effectiveAnalyticsId = user?.analyticsId || analyticsId || userId
  357. AnalyticsManager.setUserPropertyForAnalyticsId(
  358. effectiveAnalyticsId,
  359. `split-test-${splitTestName}-${versionNumber}`,
  360. selectedVariantName
  361. ).catch(err => {
  362. logger.warn(
  363. {
  364. err,
  365. analyticsId: effectiveAnalyticsId,
  366. splitTest: splitTestName,
  367. versionNumber,
  368. variant: selectedVariantName,
  369. },
  370. 'failed to set user property for analytics id'
  371. )
  372. })
  373. }
  374. let isFirstNonDefaultAssignment
  375. if (userId) {
  376. isFirstNonDefaultAssignment = metadata.isFirstNonDefaultAssignment
  377. } else {
  378. const assignments =
  379. await SplitTestSessionHandler.promises.getAssignments(session)
  380. isFirstNonDefaultAssignment = !assignments?.[splitTestName]
  381. }
  382. return _makeAssignment({
  383. variant: selectedVariantName,
  384. currentVersion,
  385. isFirstNonDefaultAssignment,
  386. })
  387. }
  388. return DEFAULT_ASSIGNMENT
  389. }
  390. async function _getAssignmentMetadata(analyticsId, user, splitTest) {
  391. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  392. const phase = currentVersion.phase
  393. if (
  394. (phase === ALPHA_PHASE && !user?.alphaProgram) ||
  395. (phase === BETA_PHASE && !user?.betaProgram)
  396. ) {
  397. return {
  398. activeForUser: false,
  399. }
  400. }
  401. const userId = user?._id.toString()
  402. const percentile = getPercentile(analyticsId || userId, splitTest.name, phase)
  403. const selectedVariantName =
  404. _getVariantFromPercentile(currentVersion.variants, percentile) ||
  405. DEFAULT_VARIANT
  406. return {
  407. activeForUser: true,
  408. selectedVariantName,
  409. phase,
  410. versionNumber: currentVersion.versionNumber,
  411. isFirstNonDefaultAssignment:
  412. selectedVariantName !== DEFAULT_VARIANT &&
  413. currentVersion.analyticsEnabled &&
  414. (!Array.isArray(user?.splitTests?.[splitTest.name]) ||
  415. !user?.splitTests?.[splitTest.name]?.some(
  416. assignment => assignment.variantName !== DEFAULT_VARIANT
  417. )),
  418. }
  419. }
  420. function getPercentile(analyticsId, splitTestName, splitTestPhase) {
  421. const hash = crypto
  422. .createHash('md5')
  423. .update(analyticsId + splitTestName + splitTestPhase)
  424. .digest('hex')
  425. const hashPrefix = hash.substr(0, 8)
  426. return Math.floor(
  427. ((parseInt(hashPrefix, 16) % 0xffffffff) / 0xffffffff) * 100
  428. )
  429. }
  430. function setOverrideInSession(session, splitTestName, variantName) {
  431. if (!Settings.devToolbar.enabled) {
  432. return
  433. }
  434. if (!session.splitTestOverrides) {
  435. session.splitTestOverrides = {}
  436. }
  437. session.splitTestOverrides[splitTestName] = variantName
  438. }
  439. function clearOverridesInSession(session) {
  440. delete session.splitTestOverrides
  441. }
  442. function _getVariantFromPercentile(variants, percentile) {
  443. for (const variant of variants) {
  444. for (const stripe of variant.rolloutStripes) {
  445. if (percentile >= stripe.start && percentile < stripe.end) {
  446. return variant.name
  447. }
  448. }
  449. }
  450. }
  451. async function _recordAssignment({
  452. user,
  453. userId,
  454. splitTestName,
  455. phase,
  456. versionNumber,
  457. variantName,
  458. }) {
  459. const persistedAssignment = {
  460. variantName,
  461. versionNumber,
  462. phase,
  463. assignedAt: new Date(),
  464. }
  465. user =
  466. user || (await SplitTestUserGetter.promises.getUser(userId, splitTestName))
  467. if (user) {
  468. const assignedSplitTests = user.splitTests || []
  469. const assignmentLog = assignedSplitTests[splitTestName] || []
  470. const existingAssignment = _.find(assignmentLog, { versionNumber })
  471. if (!existingAssignment) {
  472. await UserUpdater.promises.updateUser(userId, {
  473. $addToSet: {
  474. [`splitTests.${splitTestName}`]: persistedAssignment,
  475. },
  476. })
  477. }
  478. }
  479. }
  480. function _makeAssignment({
  481. variant,
  482. currentVersion,
  483. isFirstNonDefaultAssignment,
  484. }) {
  485. return {
  486. variant,
  487. metadata: {
  488. phase: currentVersion.phase,
  489. versionNumber: currentVersion.versionNumber,
  490. isFirstNonDefaultAssignment,
  491. },
  492. }
  493. }
  494. async function _loadSplitTestInfoInLocals(locals, splitTestName, session) {
  495. const splitTest = await _getSplitTest(splitTestName)
  496. if (splitTest) {
  497. const override = session?.splitTestOverrides?.[splitTestName]
  498. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  499. if (!currentVersion.active && !Settings.devToolbar.enabled) {
  500. return
  501. }
  502. const phase = currentVersion.phase
  503. const info = {
  504. phase,
  505. badgeInfo: splitTest.badgeInfo?.[phase],
  506. }
  507. if (Settings.devToolbar.enabled) {
  508. info.active = currentVersion.active
  509. info.variants = currentVersion.variants.map(variant => ({
  510. name: variant.name,
  511. rolloutPercent: variant.rolloutPercent,
  512. }))
  513. info.hasOverride = !!override
  514. }
  515. LocalsHelper.setSplitTestInfo(locals, splitTestName, info)
  516. } else if (Settings.devToolbar.enabled) {
  517. LocalsHelper.setSplitTestInfo(locals, splitTestName, {
  518. missing: true,
  519. })
  520. }
  521. }
  522. function _getNonSaasAssignment(splitTestName) {
  523. if (Settings.splitTestOverrides?.[splitTestName]) {
  524. return {
  525. variant: Settings.splitTestOverrides?.[splitTestName],
  526. metadata: {},
  527. }
  528. }
  529. return DEFAULT_ASSIGNMENT
  530. }
  531. async function _getSplitTest(name) {
  532. const splitTests = await SplitTestCache.get('')
  533. const splitTest = splitTests?.get(name)
  534. if (splitTest && !splitTest.archived) {
  535. return splitTest
  536. }
  537. }
  538. async function isSplitTestActive(splitTestName) {
  539. try {
  540. const splitTest = await _getSplitTest(splitTestName)
  541. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  542. return currentVersion?.active
  543. } catch (e) {
  544. logger.log('unable to check if split test is active ', splitTestName)
  545. }
  546. }
  547. module.exports = {
  548. getPercentile,
  549. getAssignment: callbackify(getAssignment),
  550. getAssignmentForMongoUser: callbackify(getAssignmentForMongoUser),
  551. getAssignmentForUser: callbackify(getAssignmentForUser),
  552. getOneTimeAssignment: callbackify(getOneTimeAssignment),
  553. getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
  554. setOverrideInSession,
  555. clearOverridesInSession,
  556. promises: {
  557. getAssignment,
  558. getAssignmentForMongoUser,
  559. getAssignmentForUser,
  560. getOneTimeAssignment,
  561. getActiveAssignmentsForUser,
  562. isSplitTestActive,
  563. },
  564. }