SplitTestHandler.js 15 KB

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