SplitTestV2Handler.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. const UserGetter = require('../User/UserGetter')
  2. const UserUpdater = require('../User/UserUpdater')
  3. const AnalyticsManager = require('../Analytics/AnalyticsManager')
  4. const UserAnalyticsIdCache = require('../Analytics/UserAnalyticsIdCache')
  5. const crypto = require('crypto')
  6. const _ = require('lodash')
  7. const { callbackify } = require('util')
  8. const splitTestCache = require('./SplitTestCache')
  9. const DEFAULT_VARIANT = 'default'
  10. const ALPHA_PHASE = 'alpha'
  11. const BETA_PHASE = 'beta'
  12. /**
  13. * Get the assignment of a user to a split test.
  14. *
  15. * @example
  16. * // Assign user and record an event
  17. *
  18. * const assignment = await SplitTestV2Handler.getAssignment(userId, 'example-project')
  19. * if (assignment.variant === 'awesome-new-version') {
  20. * // execute my awesome change
  21. * }
  22. * else {
  23. * // execute the default behaviour (control group)
  24. * }
  25. * // then record an event
  26. * AnalyticsManager.recordEventForUser(userId, 'example-project-created', {
  27. * projectId: project._id,
  28. * ...assignment.analytics.segmentation
  29. * })
  30. *
  31. * @param userId the user's ID
  32. * @param splitTestName the unique name of the split test
  33. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  34. * @returns {Promise<{variant: string, analytics: {segmentation: {splitTest: string, variant: string, phase: string, versionNumber: number}|{}}}>}
  35. */
  36. async function getAssignment(userId, splitTestName, options) {
  37. if (!userId) {
  38. return { variant: 'default', analytics: { segmentation: {} } }
  39. }
  40. const analyticsId = await UserAnalyticsIdCache.get(userId)
  41. return _getAssignment(analyticsId, userId, undefined, splitTestName, options)
  42. }
  43. /**
  44. * Get the assignment of a user to a split test by their session.
  45. *
  46. * @example
  47. * // Assign user and record an event
  48. *
  49. * const assignment = await SplitTestV2Handler.getAssignment(req.session, 'example-project')
  50. * if (assignment.variant === 'awesome-new-version') {
  51. * // execute my awesome change
  52. * }
  53. * else {
  54. * // execute the default behaviour (control group)
  55. * }
  56. * // then record an event
  57. * AnalyticsManager.recordEventForSession(req.session, 'example-project-created', {
  58. * projectId: project._id,
  59. * ...assignment.analytics.segmentation
  60. * })
  61. *
  62. * @param session the request session
  63. * @param splitTestName the unique name of the split test
  64. * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
  65. * @returns {Promise<{variant: string, analytics: {segmentation: {splitTest: string, variant: string, phase: string, versionNumber: number}|{}}}>}
  66. */
  67. async function getAssignmentForSession(session, splitTestName, options) {
  68. const { userId, analyticsId } = AnalyticsManager.getIdsFromSession(session)
  69. return _getAssignment(analyticsId, userId, session, splitTestName, options)
  70. }
  71. async function _getAssignment(
  72. analyticsId,
  73. userId,
  74. session,
  75. splitTestName,
  76. options
  77. ) {
  78. const splitTest = await splitTestCache.get(splitTestName)
  79. if (splitTest) {
  80. const currentVersion = splitTest.getCurrentVersion()
  81. if (currentVersion.active) {
  82. const {
  83. activeForUser,
  84. selectedVariantName,
  85. phase,
  86. versionNumber,
  87. } = await _getAssignmentMetadata(analyticsId, userId, splitTest)
  88. if (activeForUser) {
  89. const assignmentConfig = {
  90. userId,
  91. analyticsId,
  92. session,
  93. splitTestName,
  94. variantName: selectedVariantName,
  95. phase,
  96. versionNumber,
  97. }
  98. if (options && options.sync === true) {
  99. await _updateVariantAssignment(assignmentConfig)
  100. } else {
  101. _updateVariantAssignment(assignmentConfig)
  102. }
  103. return {
  104. variant: selectedVariantName,
  105. analytics: {
  106. segmentation: {
  107. splitTest: splitTestName,
  108. variant: selectedVariantName,
  109. phase,
  110. versionNumber,
  111. },
  112. },
  113. }
  114. }
  115. }
  116. }
  117. return {
  118. variant: DEFAULT_VARIANT,
  119. analytics: {
  120. segmentation: {},
  121. },
  122. }
  123. }
  124. async function assignInLocalsContext(res, userId, splitTestName, options) {
  125. const assignment = await getAssignment(userId, splitTestName, options)
  126. if (!res.locals.splitTestVariants) {
  127. res.locals.splitTestVariants = {}
  128. }
  129. res.locals.splitTestVariants[splitTestName] = assignment.variant
  130. }
  131. async function assignInLocalsContextForSession(
  132. res,
  133. session,
  134. splitTestName,
  135. options
  136. ) {
  137. const assignment = await getAssignmentForSession(
  138. session,
  139. splitTestName,
  140. options
  141. )
  142. if (!res.locals.splitTestVariants) {
  143. res.locals.splitTestVariants = {}
  144. }
  145. res.locals.splitTestVariants[splitTestName] = assignment.variant
  146. }
  147. async function _getAssignmentMetadata(analyticsId, userId, splitTest) {
  148. const currentVersion = splitTest.getCurrentVersion()
  149. const phase = currentVersion.phase
  150. if ([ALPHA_PHASE, BETA_PHASE].includes(phase)) {
  151. if (userId) {
  152. const user = await _getUser(userId)
  153. if (
  154. (phase === ALPHA_PHASE && !(user && user.alphaProgram)) ||
  155. (phase === BETA_PHASE && !(user && user.betaProgram))
  156. ) {
  157. return {
  158. activeForUser: false,
  159. }
  160. }
  161. } else {
  162. return {
  163. activeForUser: false,
  164. }
  165. }
  166. }
  167. const percentile = _getPercentile(analyticsId, splitTest.name, phase)
  168. const selectedVariantName = _getVariantFromPercentile(
  169. currentVersion.variants,
  170. percentile
  171. )
  172. return {
  173. activeForUser: true,
  174. selectedVariantName: selectedVariantName || DEFAULT_VARIANT,
  175. phase,
  176. versionNumber: currentVersion.versionNumber,
  177. }
  178. }
  179. function _getPercentile(analyticsId, splitTestName, splitTestPhase) {
  180. const hash = crypto
  181. .createHash('md5')
  182. .update(analyticsId + splitTestName + splitTestPhase)
  183. .digest('hex')
  184. const hashPrefix = hash.substr(0, 8)
  185. return Math.floor(
  186. ((parseInt(hashPrefix, 16) % 0xffffffff) / 0xffffffff) * 100
  187. )
  188. }
  189. function _getVariantFromPercentile(variants, percentile) {
  190. for (const variant of variants) {
  191. for (const stripe of variant.rolloutStripes) {
  192. if (percentile >= stripe.start && percentile < stripe.end) {
  193. return variant.name
  194. }
  195. }
  196. }
  197. }
  198. async function _updateVariantAssignment({
  199. userId,
  200. analyticsId,
  201. session,
  202. splitTestName,
  203. phase,
  204. versionNumber,
  205. variantName,
  206. }) {
  207. const persistedAssignment = {
  208. variantName,
  209. versionNumber,
  210. phase,
  211. assignedAt: new Date(),
  212. }
  213. if (userId) {
  214. const user = await _getUser(userId)
  215. if (user) {
  216. const assignedSplitTests = user.splitTests || []
  217. const assignmentLog = assignedSplitTests[splitTestName] || []
  218. const existingAssignment = _.find(assignmentLog, { versionNumber })
  219. if (!existingAssignment) {
  220. await UserUpdater.promises.updateUser(userId, {
  221. $addToSet: {
  222. [`splitTests.${splitTestName}`]: persistedAssignment,
  223. },
  224. })
  225. AnalyticsManager.setUserPropertyForAnalyticsId(
  226. analyticsId,
  227. `split-test-${splitTestName}-${versionNumber}`,
  228. variantName
  229. )
  230. }
  231. }
  232. } else if (session) {
  233. if (!session.splitTests) {
  234. session.splitTests = {}
  235. }
  236. if (!session.splitTests[splitTestName]) {
  237. session.splitTests[splitTestName] = []
  238. }
  239. const existingAssignment = _.find(session.splitTests[splitTestName], {
  240. versionNumber,
  241. })
  242. if (!existingAssignment) {
  243. session.splitTests[splitTestName].push(persistedAssignment)
  244. AnalyticsManager.setUserPropertyForAnalyticsId(
  245. analyticsId,
  246. `split-test-${splitTestName}-${versionNumber}`,
  247. variantName
  248. )
  249. }
  250. }
  251. }
  252. async function _getUser(id) {
  253. return UserGetter.promises.getUser(id, {
  254. splitTests: 1,
  255. alphaProgram: 1,
  256. betaProgram: 1,
  257. })
  258. }
  259. module.exports = {
  260. getAssignment: callbackify(getAssignment),
  261. getAssignmentForSession: callbackify(getAssignmentForSession),
  262. assignInLocalsContext: callbackify(assignInLocalsContext),
  263. assignInLocalsContextForSession: callbackify(assignInLocalsContextForSession),
  264. promises: {
  265. getAssignment,
  266. getAssignmentForSession,
  267. assignInLocalsContext,
  268. assignInLocalsContextForSession,
  269. },
  270. }