SplitTestSessionHandler.mjs 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. const { callbackify } = require('util')
  2. const _ = require('lodash')
  3. const { ObjectId } = require('mongodb-legacy')
  4. const logger = require('@overleaf/logger')
  5. const Metrics = require('@overleaf/metrics')
  6. const SessionManager = require('../Authentication/SessionManager')
  7. const SplitTestCache = require('./SplitTestCache')
  8. const SplitTestUtils = require('./SplitTestUtils')
  9. const SplitTestUserGetter = require('./SplitTestUserGetter')
  10. const CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER = null
  11. const TOKEN_SEP = ';'
  12. // this is safe to use as a separator adjacent to a base64 string because Mongo object IDs
  13. // do not generate any padding when converted (24 hex digits = 12 bytes => multiple of 6),
  14. // thus do not contain any trailing `=`
  15. const KEY_VALUE_SEP = '='
  16. const ID_VERSION_SEP = '_'
  17. const VARIANT_DATE_SEP = ':'
  18. async function getAssignments(session) {
  19. await _convertAnonymousAssignmentsIfNeeded(session)
  20. if (!session.sta) {
  21. return undefined
  22. }
  23. const assignments = {}
  24. const tokens = session.sta.split(TOKEN_SEP)
  25. const splitTests = Array.from((await SplitTestCache.get('')).values())
  26. for (const token of tokens) {
  27. try {
  28. if (!token.length) {
  29. continue
  30. }
  31. const [splitTestNameVersion, info] = token.split(KEY_VALUE_SEP)
  32. const [splitTestId64, versionStr] =
  33. splitTestNameVersion.split(ID_VERSION_SEP)
  34. const splitTest = splitTests.find(
  35. test => splitTestId64 === _convertIdToBase64(test._id)
  36. )
  37. if (!splitTest) {
  38. continue
  39. }
  40. const splitTestName = splitTest.name
  41. const versionNumber = parseInt(versionStr)
  42. const [variantChar, timestampStr36] = info.split(VARIANT_DATE_SEP)
  43. const assignedAt = new Date(parseInt(timestampStr36, 36) * 1000)
  44. let variantName
  45. if (variantChar === 'd') {
  46. variantName = 'default'
  47. } else {
  48. const variantIndex = parseInt(variantChar)
  49. variantName =
  50. SplitTestUtils.getCurrentVersion(splitTest).variants[variantIndex]
  51. .name
  52. }
  53. if (!assignments[splitTestName]) {
  54. assignments[splitTestName] = []
  55. }
  56. if (
  57. !_.find(assignments[splitTestName], {
  58. versionNumber,
  59. variantName,
  60. })
  61. )
  62. assignments[splitTestName].push({
  63. versionNumber,
  64. variantName,
  65. phase: 'release', // anonymous users can only be exposed to tests in release phase
  66. assignedAt,
  67. })
  68. } catch (error) {
  69. logger.error(
  70. { err: error, token },
  71. 'Failed to resolve cached anonymous split test assignments from session'
  72. )
  73. }
  74. }
  75. return assignments
  76. }
  77. async function appendAssignment(session, assignment) {
  78. await _convertAnonymousAssignmentsIfNeeded(session)
  79. if (
  80. !_hasExistingAssignment(
  81. session,
  82. assignment.splitTestId,
  83. assignment.versionNumber
  84. )
  85. ) {
  86. if (!session.sta) {
  87. session.sta = ''
  88. }
  89. const splitTests = await SplitTestCache.get('')
  90. const splitTest = splitTests.get(assignment.splitTestName)
  91. const assignmentString = _buildAssignmentString(splitTest, assignment)
  92. const separator = session.sta.length > 0 ? TOKEN_SEP : ''
  93. session.sta += `${separator}${assignmentString}`
  94. }
  95. }
  96. function getCachedVariant(session, splitTestName, currentVersion) {
  97. if (!session.cachedSplitTestAssignments) {
  98. session.cachedSplitTestAssignments = {}
  99. }
  100. const cacheKey = `${splitTestName}-${currentVersion.versionNumber}`
  101. return session.cachedSplitTestAssignments[cacheKey]
  102. }
  103. function setVariantInCache({
  104. session,
  105. splitTestName,
  106. currentVersion,
  107. selectedVariantName,
  108. activeForUser,
  109. }) {
  110. if (!session.cachedSplitTestAssignments) {
  111. session.cachedSplitTestAssignments = {}
  112. }
  113. // clean up previous entries from this split test
  114. for (const cacheKey of Object.keys(session.cachedSplitTestAssignments)) {
  115. // drop '-versionNumber'
  116. const name = cacheKey.split('-').slice(0, -1).join('-')
  117. if (name === splitTestName) {
  118. delete session.cachedSplitTestAssignments[cacheKey]
  119. }
  120. }
  121. const cacheKey = `${splitTestName}-${currentVersion.versionNumber}`
  122. if (activeForUser) {
  123. session.cachedSplitTestAssignments[cacheKey] = selectedVariantName
  124. } else {
  125. session.cachedSplitTestAssignments[cacheKey] =
  126. CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER
  127. }
  128. }
  129. /**
  130. * @param {import('express').Request} req
  131. * @param {Object|null} user optional, prefetched user with alphaProgram and betaProgram field
  132. * @return {Promise<void>}
  133. */
  134. async function sessionMaintenance(req, user) {
  135. const session = req.session
  136. const sessionUser = SessionManager.getSessionUser(session)
  137. Metrics.inc('split_test_session_maintenance', 1, { status: 'start' })
  138. if (sessionUser) {
  139. user = user || (await SplitTestUserGetter.promises.getUser(sessionUser._id))
  140. if (
  141. Boolean(sessionUser.alphaProgram) !== Boolean(user.alphaProgram) ||
  142. Boolean(sessionUser.betaProgram) !== Boolean(user.betaProgram)
  143. ) {
  144. Metrics.inc('split_test_session_maintenance', 1, {
  145. status: 'program-change',
  146. })
  147. sessionUser.alphaProgram = user.alphaProgram || undefined // only store if set
  148. sessionUser.betaProgram = user.betaProgram || undefined // only store if set
  149. session.cachedSplitTestAssignments = {}
  150. }
  151. }
  152. // TODO: After changing the split test config fetching: remove split test assignments for archived split tests
  153. }
  154. function collectSessionStats(session) {
  155. if (session.cachedSplitTestAssignments) {
  156. Metrics.summary(
  157. 'split_test_session_cache_count',
  158. Object.keys(session.cachedSplitTestAssignments).length
  159. )
  160. Metrics.summary(
  161. 'split_test_session_cache_size',
  162. JSON.stringify(session.cachedSplitTestAssignments).length
  163. )
  164. }
  165. if (session.sta) {
  166. Metrics.summary(
  167. 'split_test_session_storage_count',
  168. (session.sta || '').split(';').length
  169. )
  170. Metrics.summary(
  171. 'split_test_session_storage_size',
  172. (session.sta || '').length
  173. )
  174. }
  175. }
  176. async function _convertAnonymousAssignmentsIfNeeded(session) {
  177. if (session.splitTests) {
  178. const splitTests = await SplitTestCache.get('')
  179. if (!session.sta) {
  180. session.sta = ''
  181. }
  182. for (const [splitTestName, assignments] of Object.entries(
  183. session.splitTests || {}
  184. )) {
  185. const splitTest = splitTests.get(splitTestName)
  186. for (const assignment of assignments) {
  187. const assignmentString = _buildAssignmentString(splitTest, assignment)
  188. const separator = session.sta.length > 0 ? TOKEN_SEP : ''
  189. if (!session.sta.includes(assignmentString)) {
  190. session.sta += `${separator}${assignmentString}`
  191. }
  192. }
  193. }
  194. delete session.splitTests
  195. }
  196. }
  197. function _hasExistingAssignment(session, splitTest, versionNumber) {
  198. if (!session.sta) {
  199. return false
  200. }
  201. const index = session.sta.indexOf(
  202. `${_convertIdToBase64(splitTest._id)}${ID_VERSION_SEP}${versionNumber}=`
  203. )
  204. return index >= 0
  205. }
  206. function _buildAssignmentString(splitTest, assignment) {
  207. const { versionNumber, variantName, assignedAt } = assignment
  208. const variants = SplitTestUtils.getCurrentVersion(splitTest).variants
  209. const splitTestId = _convertIdToBase64(splitTest._id)
  210. const variantChar =
  211. variantName === 'default'
  212. ? 'd'
  213. : _.findIndex(variants, { name: variantName })
  214. const timestamp = Math.floor(new Date(assignedAt).getTime() / 1000).toString(
  215. 36
  216. )
  217. return `${splitTestId}${ID_VERSION_SEP}${versionNumber}${KEY_VALUE_SEP}${variantChar}${VARIANT_DATE_SEP}${timestamp}`
  218. }
  219. function _convertIdToBase64(id) {
  220. return new ObjectId(id).toString('base64')
  221. }
  222. module.exports = {
  223. getAssignments: callbackify(getAssignments),
  224. appendAssignment: callbackify(appendAssignment),
  225. getCachedVariant,
  226. setVariantInCache,
  227. sessionMaintenance: callbackify(sessionMaintenance),
  228. collectSessionStats,
  229. CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER,
  230. promises: {
  231. getAssignments,
  232. appendAssignment,
  233. sessionMaintenance,
  234. },
  235. }