SplitTestSessionHandler.mjs 8.2 KB

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