SplitTestHandler.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  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. const DEFAULT_VARIANT = 'default'
  19. const ALPHA_PHASE = 'alpha'
  20. const BETA_PHASE = 'beta'
  21. const DEFAULT_ASSIGNMENT = {
  22. variant: DEFAULT_VARIANT,
  23. analytics: {
  24. segmentation: {},
  25. },
  26. }
  27. /**
  28. * Get the assignment of a user to a split test and store it in the response locals context
  29. *
  30. * @example
  31. * // Assign user and record an event
  32. *
  33. * const assignment = await SplitTestHandler.getAssignment(req, res, 'example-project')
  34. * if (assignment.variant === 'awesome-new-version') {
  35. * // execute my awesome change
  36. * }
  37. * else {
  38. * // execute the default behaviour (control group)
  39. * }
  40. * // then record an event
  41. * AnalyticsManager.recordEventForSession(req.session, 'example-project-created', {
  42. * projectId: project._id,
  43. * ...assignment.analytics.segmentation
  44. * })
  45. *
  46. * @param req the request
  47. * @param res the Express response object
  48. * @param splitTestName the unique name of the split test
  49. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  50. * @returns {Promise<{variant: string, analytics: {segmentation: {splitTest: string, variant: string, phase: string, versionNumber: number}|{}}}>}
  51. */
  52. async function getAssignment(req, res, splitTestName, { sync = false } = {}) {
  53. const query = req.query || {}
  54. let assignment
  55. try {
  56. if (!Features.hasFeature('saas')) {
  57. assignment = _getNonSaasAssignment(splitTestName)
  58. } else {
  59. await _loadSplitTestInfoInLocals(res.locals, splitTestName, req.session)
  60. // Check the query string for an override, ignoring an invalid value
  61. const queryVariant = query[splitTestName]
  62. if (queryVariant) {
  63. const variants = await _getVariantNames(splitTestName)
  64. if (variants.includes(queryVariant)) {
  65. assignment = {
  66. variant: queryVariant,
  67. analytics: {
  68. segmentation: {},
  69. },
  70. }
  71. }
  72. }
  73. if (!assignment) {
  74. const { userId, analyticsId } = AnalyticsManager.getIdsFromSession(
  75. req.session
  76. )
  77. assignment = await _getAssignment(splitTestName, {
  78. analyticsId,
  79. userId,
  80. session: req.session,
  81. sync,
  82. })
  83. SplitTestSessionHandler.collectSessionStats(req.session)
  84. }
  85. }
  86. } catch (error) {
  87. logger.error({ err: error }, 'Failed to get split test assignment')
  88. assignment = DEFAULT_ASSIGNMENT
  89. }
  90. LocalsHelper.setSplitTestVariant(
  91. res.locals,
  92. splitTestName,
  93. assignment.variant
  94. )
  95. return assignment
  96. }
  97. /**
  98. * Get the assignment of a user to a split test by their user ID.
  99. *
  100. * Warning: this does not support query parameters override, nor makes the assignment and split test info available to
  101. * the frontend through locals. Wherever possible, `getAssignment` should be used instead.
  102. *
  103. * @param userId the user ID
  104. * @param splitTestName the unique name of the split test
  105. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  106. * @returns {Promise<{variant: string, analytics: {segmentation: {splitTest: string, variant: string, phase: string, versionNumber: number}|{}}}>}
  107. */
  108. async function getAssignmentForUser(
  109. userId,
  110. splitTestName,
  111. { sync = false } = {}
  112. ) {
  113. try {
  114. if (!Features.hasFeature('saas')) {
  115. return _getNonSaasAssignment(splitTestName)
  116. }
  117. const analyticsId = await UserAnalyticsIdCache.get(userId)
  118. return _getAssignment(splitTestName, { analyticsId, userId, sync })
  119. } catch (error) {
  120. logger.error({ err: error }, 'Failed to get split test assignment for user')
  121. return DEFAULT_ASSIGNMENT
  122. }
  123. }
  124. /**
  125. * Get the assignment of a user to a split test by their pre-fetched mongo doc.
  126. *
  127. * Warning: this does not support query parameters override, nor makes the assignment and split test info available to
  128. * the frontend through locals. Wherever possible, `getAssignment` should be used instead.
  129. *
  130. * @param user the user
  131. * @param splitTestName the unique name of the split test
  132. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  133. * @returns {Promise<{variant: string, analytics: {segmentation: {splitTest: string, variant: string, phase: string, versionNumber: number}|{}}}>}
  134. */
  135. async function getAssignmentForMongoUser(
  136. user,
  137. splitTestName,
  138. { sync = false } = {}
  139. ) {
  140. try {
  141. if (!Features.hasFeature('saas')) {
  142. return _getNonSaasAssignment(splitTestName)
  143. }
  144. return _getAssignment(splitTestName, {
  145. analyticsId: await UserAnalyticsIdCache.get(user._id),
  146. sync,
  147. user,
  148. userId: user._id.toString(),
  149. })
  150. } catch (error) {
  151. logger.error(
  152. { err: error },
  153. 'Failed to get split test assignment for mongo user'
  154. )
  155. return DEFAULT_ASSIGNMENT
  156. }
  157. }
  158. /**
  159. * Get a mapping of the active split test assignments for the given user
  160. */
  161. async function getActiveAssignmentsForUser(userId, removeArchived = false) {
  162. if (!Features.hasFeature('saas')) {
  163. return {}
  164. }
  165. const user = await SplitTestUserGetter.promises.getUser(userId)
  166. if (user == null) {
  167. return {}
  168. }
  169. const splitTests = await SplitTest.find({
  170. $where: 'this.versions[this.versions.length - 1].active',
  171. ...(removeArchived && { archived: { $ne: true } }),
  172. }).exec()
  173. const assignments = {}
  174. for (const splitTest of splitTests) {
  175. const { activeForUser, selectedVariantName, phase, versionNumber } =
  176. await _getAssignmentMetadata(user.analyticsId, user, splitTest)
  177. if (activeForUser) {
  178. const assignment = {
  179. variantName: selectedVariantName,
  180. versionNumber,
  181. phase,
  182. }
  183. const userAssignments = user.splitTests?.[splitTest.name]
  184. if (Array.isArray(userAssignments)) {
  185. const userAssignment = userAssignments.find(
  186. x => x.versionNumber === versionNumber
  187. )
  188. if (userAssignment) {
  189. assignment.assignedAt = userAssignment.assignedAt
  190. }
  191. }
  192. assignments[splitTest.name] = assignment
  193. }
  194. }
  195. return assignments
  196. }
  197. /**
  198. * Returns an array of valid variant names for the given split test, including default
  199. *
  200. * @param splitTestName
  201. * @returns {Promise<string[]>}
  202. * @private
  203. */
  204. async function _getVariantNames(splitTestName) {
  205. const splitTest = await _getSplitTest(splitTestName)
  206. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  207. if (currentVersion?.active) {
  208. return currentVersion.variants.map(v => v.name).concat([DEFAULT_VARIANT])
  209. } else {
  210. return [DEFAULT_VARIANT]
  211. }
  212. }
  213. async function _getAssignment(
  214. splitTestName,
  215. { analyticsId, user, userId, session, sync }
  216. ) {
  217. if (!analyticsId && !userId) {
  218. return DEFAULT_ASSIGNMENT
  219. }
  220. const splitTest = await _getSplitTest(splitTestName)
  221. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  222. if (Settings.splitTest.devToolbar.enabled) {
  223. const override = session?.splitTestOverrides?.[splitTestName]
  224. if (override) {
  225. return _makeAssignment(splitTest, override, currentVersion)
  226. }
  227. }
  228. if (!currentVersion?.active) {
  229. return DEFAULT_ASSIGNMENT
  230. }
  231. // Do not cache assignments for anonymous users. All the context for their assignments is in the session:
  232. // They cannot be part of the alpha or beta program, and they will use their analyticsId for assignments.
  233. const canUseSessionCache = session && SessionManager.isUserLoggedIn(session)
  234. if (session && !canUseSessionCache) {
  235. // Purge the existing cache
  236. delete session.cachedSplitTestAssignments
  237. }
  238. if (canUseSessionCache) {
  239. const cachedVariant = SplitTestSessionHandler.getCachedVariant(
  240. session,
  241. splitTest.name,
  242. currentVersion
  243. )
  244. if (cachedVariant) {
  245. Metrics.inc('split_test_get_assignment_source', 1, { status: 'cache' })
  246. if (
  247. cachedVariant ===
  248. SplitTestSessionHandler.CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER
  249. ) {
  250. return DEFAULT_ASSIGNMENT
  251. } else {
  252. return _makeAssignment(splitTest, cachedVariant, currentVersion)
  253. }
  254. }
  255. }
  256. if (user) {
  257. Metrics.inc('split_test_get_assignment_source', 1, { status: 'provided' })
  258. } else if (userId) {
  259. Metrics.inc('split_test_get_assignment_source', 1, { status: 'mongo' })
  260. } else {
  261. Metrics.inc('split_test_get_assignment_source', 1, { status: 'none' })
  262. }
  263. user =
  264. user ||
  265. (userId &&
  266. (await SplitTestUserGetter.promises.getUser(userId, splitTestName)))
  267. const { activeForUser, selectedVariantName, phase, versionNumber } =
  268. await _getAssignmentMetadata(analyticsId, user, splitTest)
  269. if (canUseSessionCache) {
  270. SplitTestSessionHandler.setVariantInCache({
  271. session,
  272. splitTestName,
  273. currentVersion,
  274. selectedVariantName,
  275. activeForUser,
  276. })
  277. }
  278. if (activeForUser) {
  279. if (currentVersion.analyticsEnabled) {
  280. // if the user is logged in, persist the assignment
  281. if (userId) {
  282. const assignmentData = {
  283. user,
  284. userId,
  285. splitTestName,
  286. phase,
  287. versionNumber,
  288. variantName: selectedVariantName,
  289. }
  290. if (sync === true) {
  291. await _recordAssignment(assignmentData)
  292. } else {
  293. _recordAssignment(assignmentData)
  294. }
  295. }
  296. // otherwise this is an anonymous user, we store assignments in session to persist them on registration
  297. else {
  298. await SplitTestSessionHandler.promises.appendAssignment(session, {
  299. splitTestId: splitTest._id,
  300. splitTestName,
  301. phase,
  302. versionNumber,
  303. variantName: selectedVariantName,
  304. assignedAt: new Date(),
  305. })
  306. }
  307. AnalyticsManager.setUserPropertyForAnalyticsId(
  308. user?.analyticsId || analyticsId || userId,
  309. `split-test-${splitTestName}-${versionNumber}`,
  310. selectedVariantName
  311. )
  312. }
  313. return _makeAssignment(splitTest, selectedVariantName, currentVersion)
  314. }
  315. return DEFAULT_ASSIGNMENT
  316. }
  317. async function _getAssignmentMetadata(analyticsId, user, splitTest) {
  318. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  319. const phase = currentVersion.phase
  320. if (
  321. (phase === ALPHA_PHASE && !user?.alphaProgram) ||
  322. (phase === BETA_PHASE && !user?.betaProgram)
  323. ) {
  324. return {
  325. activeForUser: false,
  326. }
  327. }
  328. const userId = user?._id.toString()
  329. const percentile = getPercentile(analyticsId || userId, splitTest.name, phase)
  330. const selectedVariantName = _getVariantFromPercentile(
  331. currentVersion.variants,
  332. percentile
  333. )
  334. return {
  335. activeForUser: true,
  336. selectedVariantName: selectedVariantName || DEFAULT_VARIANT,
  337. phase,
  338. versionNumber: currentVersion.versionNumber,
  339. }
  340. }
  341. function getPercentile(analyticsId, splitTestName, splitTestPhase) {
  342. const hash = crypto
  343. .createHash('md5')
  344. .update(analyticsId + splitTestName + splitTestPhase)
  345. .digest('hex')
  346. const hashPrefix = hash.substr(0, 8)
  347. return Math.floor(
  348. ((parseInt(hashPrefix, 16) % 0xffffffff) / 0xffffffff) * 100
  349. )
  350. }
  351. function setOverrideInSession(session, splitTestName, variantName) {
  352. if (!Settings.splitTest.devToolbar.enabled) {
  353. return
  354. }
  355. if (!session.splitTestOverrides) {
  356. session.splitTestOverrides = {}
  357. }
  358. session.splitTestOverrides[splitTestName] = variantName
  359. }
  360. function clearOverridesInSession(session) {
  361. delete session.splitTestOverrides
  362. }
  363. function _getVariantFromPercentile(variants, percentile) {
  364. for (const variant of variants) {
  365. for (const stripe of variant.rolloutStripes) {
  366. if (percentile >= stripe.start && percentile < stripe.end) {
  367. return variant.name
  368. }
  369. }
  370. }
  371. }
  372. async function _recordAssignment({
  373. user,
  374. userId,
  375. splitTestName,
  376. phase,
  377. versionNumber,
  378. variantName,
  379. }) {
  380. const persistedAssignment = {
  381. variantName,
  382. versionNumber,
  383. phase,
  384. assignedAt: new Date(),
  385. }
  386. user =
  387. user || (await SplitTestUserGetter.promises.getUser(userId, splitTestName))
  388. if (user) {
  389. const assignedSplitTests = user.splitTests || []
  390. const assignmentLog = assignedSplitTests[splitTestName] || []
  391. const existingAssignment = _.find(assignmentLog, { versionNumber })
  392. if (!existingAssignment) {
  393. await UserUpdater.promises.updateUser(userId, {
  394. $addToSet: {
  395. [`splitTests.${splitTestName}`]: persistedAssignment,
  396. },
  397. })
  398. }
  399. }
  400. }
  401. function _makeAssignment(splitTest, variant, currentVersion) {
  402. return {
  403. variant,
  404. analytics: {
  405. segmentation: splitTest
  406. ? {
  407. splitTest: splitTest.name,
  408. variant,
  409. phase: currentVersion.phase,
  410. versionNumber: currentVersion.versionNumber,
  411. }
  412. : {},
  413. },
  414. }
  415. }
  416. async function _loadSplitTestInfoInLocals(locals, splitTestName, session) {
  417. const splitTest = await _getSplitTest(splitTestName)
  418. if (splitTest) {
  419. const override = session?.splitTestOverrides?.[splitTestName]
  420. const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
  421. if (!currentVersion.active && !Settings.splitTest.devToolbar.enabled) {
  422. return
  423. }
  424. const phase = currentVersion.phase
  425. const info = {
  426. phase,
  427. badgeInfo: splitTest.badgeInfo?.[phase],
  428. }
  429. if (Settings.splitTest.devToolbar.enabled) {
  430. info.active = currentVersion.active
  431. info.variants = currentVersion.variants.map(variant => ({
  432. name: variant.name,
  433. rolloutPercent: variant.rolloutPercent,
  434. }))
  435. info.hasOverride = !!override
  436. }
  437. LocalsHelper.setSplitTestInfo(locals, splitTestName, info)
  438. } else if (Settings.splitTest.devToolbar.enabled) {
  439. LocalsHelper.setSplitTestInfo(locals, splitTestName, {
  440. missing: true,
  441. })
  442. }
  443. }
  444. function _getNonSaasAssignment(splitTestName) {
  445. if (Settings.splitTestOverrides?.[splitTestName]) {
  446. return {
  447. variant: Settings.splitTestOverrides?.[splitTestName],
  448. analytics: {
  449. segmentation: {},
  450. },
  451. }
  452. }
  453. return DEFAULT_ASSIGNMENT
  454. }
  455. async function _getSplitTest(name) {
  456. const splitTests = await SplitTestCache.get('')
  457. return splitTests?.get(name)
  458. }
  459. module.exports = {
  460. getAssignment: callbackify(getAssignment),
  461. getAssignmentForMongoUser: callbackify(getAssignmentForMongoUser),
  462. getAssignmentForUser: callbackify(getAssignmentForUser),
  463. getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
  464. setOverrideInSession,
  465. clearOverridesInSession,
  466. promises: {
  467. getAssignment,
  468. getAssignmentForMongoUser,
  469. getAssignmentForUser,
  470. getActiveAssignmentsForUser,
  471. },
  472. }