|
|
@@ -1,127 +1,284 @@
|
|
|
const UserGetter = require('../User/UserGetter')
|
|
|
const UserUpdater = require('../User/UserUpdater')
|
|
|
const AnalyticsManager = require('../Analytics/AnalyticsManager')
|
|
|
-const Settings = require('@overleaf/settings')
|
|
|
-const _ = require('lodash')
|
|
|
+const LocalsHelper = require('./LocalsHelper')
|
|
|
const crypto = require('crypto')
|
|
|
-const OError = require('@overleaf/o-error')
|
|
|
+const _ = require('lodash')
|
|
|
const { callbackify } = require('util')
|
|
|
+const splitTestCache = require('./SplitTestCache')
|
|
|
|
|
|
-const duplicateSplitTest = _.findKey(
|
|
|
- _.groupBy(Settings.splitTests, 'id'),
|
|
|
- group => {
|
|
|
- return group.length > 1
|
|
|
- }
|
|
|
-)
|
|
|
-if (duplicateSplitTest) {
|
|
|
- throw new OError(
|
|
|
- `Split test IDs must be unique: ${duplicateSplitTest} is defined at least twice`
|
|
|
- )
|
|
|
+const DEFAULT_VARIANT = 'default'
|
|
|
+const ALPHA_PHASE = 'alpha'
|
|
|
+const BETA_PHASE = 'beta'
|
|
|
+const DEFAULT_ASSIGNMENT = {
|
|
|
+ variant: DEFAULT_VARIANT,
|
|
|
+ analytics: {
|
|
|
+ segmentation: {},
|
|
|
+ },
|
|
|
}
|
|
|
|
|
|
-const ACTIVE_SPLIT_TESTS = []
|
|
|
-for (const splitTest of Settings.splitTests) {
|
|
|
- for (const variant of splitTest.variants) {
|
|
|
- if (variant.id === 'default') {
|
|
|
- throw new OError(
|
|
|
- `Split test variant ID cannot be 'default' (reserved value), defined in split test ${JSON.stringify(
|
|
|
- splitTest
|
|
|
- )}`
|
|
|
- )
|
|
|
+/**
|
|
|
+ * Get the assignment of a user to a split test by their session.
|
|
|
+ *
|
|
|
+ * @example
|
|
|
+ * // Assign user and record an event
|
|
|
+ *
|
|
|
+ * const assignment = await SplitTestHandler.getAssignment(req.session, 'example-project')
|
|
|
+ * if (assignment.variant === 'awesome-new-version') {
|
|
|
+ * // execute my awesome change
|
|
|
+ * }
|
|
|
+ * else {
|
|
|
+ * // execute the default behaviour (control group)
|
|
|
+ * }
|
|
|
+ * // then record an event
|
|
|
+ * AnalyticsManager.recordEventForSession(req.session, 'example-project-created', {
|
|
|
+ * projectId: project._id,
|
|
|
+ * ...assignment.analytics.segmentation
|
|
|
+ * })
|
|
|
+ *
|
|
|
+ * @param req the request
|
|
|
+ * @param splitTestName the unique name of the split test
|
|
|
+ * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
|
|
|
+ * @returns {Promise<{variant: string, analytics: {segmentation: {splitTest: string, variant: string, phase: string, versionNumber: number}|{}}}>}
|
|
|
+ */
|
|
|
+async function getAssignment(req, splitTestName, options) {
|
|
|
+ const query = req.query || {}
|
|
|
+ if (query[splitTestName]) {
|
|
|
+ return {
|
|
|
+ variant: query[splitTestName],
|
|
|
+ analytics: {
|
|
|
+ segmentation: {},
|
|
|
+ },
|
|
|
}
|
|
|
}
|
|
|
- const totalVariantsRolloutPercent = _.sumBy(
|
|
|
- splitTest.variants,
|
|
|
- 'rolloutPercent'
|
|
|
+ const { userId, analyticsId } = AnalyticsManager.getIdsFromSession(
|
|
|
+ req.session
|
|
|
+ )
|
|
|
+ return _getAssignment(
|
|
|
+ analyticsId,
|
|
|
+ userId,
|
|
|
+ req.session,
|
|
|
+ splitTestName,
|
|
|
+ options
|
|
|
)
|
|
|
- if (splitTest.active) {
|
|
|
- if (totalVariantsRolloutPercent > 100) {
|
|
|
- for (const variant of splitTest.variants) {
|
|
|
- variant.rolloutPercent =
|
|
|
- (variant.rolloutPercent * 100) / totalVariantsRolloutPercent
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Get the assignment of a user to a split test by their session and stores it in the locals context.
|
|
|
+ *
|
|
|
+ * @param req the request
|
|
|
+ * @param res the Express response object
|
|
|
+ * @param splitTestName the unique name of the split test
|
|
|
+ * @param options {Object<sync: boolean>} - for test purposes only, to force the synchronous update of the user's profile
|
|
|
+ * @returns {Promise<void>}
|
|
|
+ */
|
|
|
+async function assignInLocalsContext(req, res, splitTestName, options) {
|
|
|
+ const assignment = await getAssignment(req, splitTestName, options)
|
|
|
+ LocalsHelper.setSplitTestVariant(
|
|
|
+ res.locals,
|
|
|
+ splitTestName,
|
|
|
+ assignment.variant
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+async function _getAssignment(
|
|
|
+ analyticsId,
|
|
|
+ userId,
|
|
|
+ session,
|
|
|
+ splitTestName,
|
|
|
+ options
|
|
|
+) {
|
|
|
+ if (!analyticsId && !userId) {
|
|
|
+ return DEFAULT_ASSIGNMENT
|
|
|
+ }
|
|
|
+ const splitTest = await splitTestCache.get(splitTestName)
|
|
|
+ if (splitTest) {
|
|
|
+ const currentVersion = splitTest.getCurrentVersion()
|
|
|
+ const cachedVariant = _getCachedVariantFromSession(
|
|
|
+ session,
|
|
|
+ splitTest.name,
|
|
|
+ currentVersion
|
|
|
+ )
|
|
|
+ if (currentVersion.active) {
|
|
|
+ if (cachedVariant) {
|
|
|
+ return _makeAssignment(splitTest, cachedVariant, currentVersion)
|
|
|
+ }
|
|
|
+ const { activeForUser, selectedVariantName, phase, versionNumber } =
|
|
|
+ await _getAssignmentMetadata(analyticsId, userId, splitTest)
|
|
|
+ if (activeForUser) {
|
|
|
+ const assignmentConfig = {
|
|
|
+ userId,
|
|
|
+ analyticsId,
|
|
|
+ session,
|
|
|
+ splitTestName,
|
|
|
+ variantName: selectedVariantName,
|
|
|
+ phase,
|
|
|
+ versionNumber,
|
|
|
+ }
|
|
|
+ if (options && options.sync === true) {
|
|
|
+ await _updateVariantAssignment(assignmentConfig)
|
|
|
+ } else {
|
|
|
+ _updateVariantAssignment(assignmentConfig)
|
|
|
+ }
|
|
|
+ return _makeAssignment(splitTest, selectedVariantName, currentVersion)
|
|
|
}
|
|
|
- }
|
|
|
- if (totalVariantsRolloutPercent > 0) {
|
|
|
- ACTIVE_SPLIT_TESTS.push(splitTest)
|
|
|
}
|
|
|
}
|
|
|
+ return DEFAULT_ASSIGNMENT
|
|
|
}
|
|
|
|
|
|
-async function getTestSegmentation(userId, splitTestId) {
|
|
|
- const splitTest = _.find(ACTIVE_SPLIT_TESTS, ['id', splitTestId])
|
|
|
- if (splitTest) {
|
|
|
- const alreadyAssignedVariant = await getAlreadyAssignedVariant(
|
|
|
- userId,
|
|
|
- splitTestId
|
|
|
- )
|
|
|
- if (alreadyAssignedVariant) {
|
|
|
- return {
|
|
|
- enabled: true,
|
|
|
- variant: alreadyAssignedVariant,
|
|
|
+async function _getAssignmentMetadata(analyticsId, userId, splitTest) {
|
|
|
+ const currentVersion = splitTest.getCurrentVersion()
|
|
|
+ const phase = currentVersion.phase
|
|
|
+ if ([ALPHA_PHASE, BETA_PHASE].includes(phase)) {
|
|
|
+ if (userId) {
|
|
|
+ const user = await _getUser(userId)
|
|
|
+ if (
|
|
|
+ (phase === ALPHA_PHASE && !(user && user.alphaProgram)) ||
|
|
|
+ (phase === BETA_PHASE && !(user && user.betaProgram))
|
|
|
+ ) {
|
|
|
+ return {
|
|
|
+ activeForUser: false,
|
|
|
+ }
|
|
|
}
|
|
|
} else {
|
|
|
- const variant = await assignUserToVariant(userId, splitTest)
|
|
|
return {
|
|
|
- enabled: true,
|
|
|
- variant,
|
|
|
+ activeForUser: false,
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+ const percentile = _getPercentile(analyticsId, splitTest.name, phase)
|
|
|
+ const selectedVariantName = _getVariantFromPercentile(
|
|
|
+ currentVersion.variants,
|
|
|
+ percentile
|
|
|
+ )
|
|
|
return {
|
|
|
- enabled: false,
|
|
|
+ activeForUser: true,
|
|
|
+ selectedVariantName: selectedVariantName || DEFAULT_VARIANT,
|
|
|
+ phase,
|
|
|
+ versionNumber: currentVersion.versionNumber,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async function getAlreadyAssignedVariant(userId, splitTestId) {
|
|
|
- const user = await UserGetter.promises.getUser(userId, { splitTests: 1 })
|
|
|
- if (user && user.splitTests) {
|
|
|
- return user.splitTests[splitTestId]
|
|
|
+function _getPercentile(analyticsId, splitTestName, splitTestPhase) {
|
|
|
+ const hash = crypto
|
|
|
+ .createHash('md5')
|
|
|
+ .update(analyticsId + splitTestName + splitTestPhase)
|
|
|
+ .digest('hex')
|
|
|
+ const hashPrefix = hash.substr(0, 8)
|
|
|
+ return Math.floor(
|
|
|
+ ((parseInt(hashPrefix, 16) % 0xffffffff) / 0xffffffff) * 100
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function _getVariantFromPercentile(variants, percentile) {
|
|
|
+ for (const variant of variants) {
|
|
|
+ for (const stripe of variant.rolloutStripes) {
|
|
|
+ if (percentile >= stripe.start && percentile < stripe.end) {
|
|
|
+ return variant.name
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
- return undefined
|
|
|
}
|
|
|
|
|
|
-async function assignUserToVariant(userId, splitTest) {
|
|
|
- let userIdAsPercentile = await _getPercentile(userId, splitTest.id)
|
|
|
- let selectedVariant = 'default'
|
|
|
- for (const variant of splitTest.variants) {
|
|
|
- if (userIdAsPercentile < variant.rolloutPercent) {
|
|
|
- selectedVariant = variant.id
|
|
|
- break
|
|
|
- } else {
|
|
|
- userIdAsPercentile -= variant.rolloutPercent
|
|
|
+async function _updateVariantAssignment({
|
|
|
+ userId,
|
|
|
+ analyticsId,
|
|
|
+ session,
|
|
|
+ splitTestName,
|
|
|
+ phase,
|
|
|
+ versionNumber,
|
|
|
+ variantName,
|
|
|
+}) {
|
|
|
+ const persistedAssignment = {
|
|
|
+ variantName,
|
|
|
+ versionNumber,
|
|
|
+ phase,
|
|
|
+ assignedAt: new Date(),
|
|
|
+ }
|
|
|
+ // if the user is logged in
|
|
|
+ if (userId) {
|
|
|
+ const user = await _getUser(userId)
|
|
|
+ if (user) {
|
|
|
+ const assignedSplitTests = user.splitTests || []
|
|
|
+ const assignmentLog = assignedSplitTests[splitTestName] || []
|
|
|
+ const existingAssignment = _.find(assignmentLog, { versionNumber })
|
|
|
+ if (!existingAssignment) {
|
|
|
+ await UserUpdater.promises.updateUser(userId, {
|
|
|
+ $addToSet: {
|
|
|
+ [`splitTests.${splitTestName}`]: persistedAssignment,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ AnalyticsManager.setUserPropertyForAnalyticsId(
|
|
|
+ analyticsId,
|
|
|
+ `split-test-${splitTestName}-${versionNumber}`,
|
|
|
+ variantName
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // otherwise this is an anonymous user, we store assignments in session to persist them on registration
|
|
|
+ else if (session) {
|
|
|
+ if (!session.splitTests) {
|
|
|
+ session.splitTests = {}
|
|
|
+ }
|
|
|
+ if (!session.splitTests[splitTestName]) {
|
|
|
+ session.splitTests[splitTestName] = []
|
|
|
+ }
|
|
|
+ const existingAssignment = _.find(session.splitTests[splitTestName], {
|
|
|
+ versionNumber,
|
|
|
+ })
|
|
|
+ if (!existingAssignment) {
|
|
|
+ session.splitTests[splitTestName].push(persistedAssignment)
|
|
|
+ AnalyticsManager.setUserPropertyForAnalyticsId(
|
|
|
+ analyticsId,
|
|
|
+ `split-test-${splitTestName}-${versionNumber}`,
|
|
|
+ variantName
|
|
|
+ )
|
|
|
}
|
|
|
}
|
|
|
- await UserUpdater.promises.updateUser(userId, {
|
|
|
- $set: {
|
|
|
- [`splitTests.${splitTest.id}`]: selectedVariant,
|
|
|
+}
|
|
|
+
|
|
|
+function _makeAssignment(splitTest, variant, currentVersion) {
|
|
|
+ return {
|
|
|
+ variant,
|
|
|
+ analytics: {
|
|
|
+ segmentation: {
|
|
|
+ splitTest: splitTest.name,
|
|
|
+ variant,
|
|
|
+ phase: currentVersion.phase,
|
|
|
+ versionNumber: currentVersion.versionNumber,
|
|
|
+ },
|
|
|
},
|
|
|
- })
|
|
|
- AnalyticsManager.setUserPropertyForUser(
|
|
|
- userId,
|
|
|
- `split-test-${splitTest.id}`,
|
|
|
- selectedVariant
|
|
|
- )
|
|
|
- return selectedVariant
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-function _getPercentile(userId, splitTestId) {
|
|
|
- const hash = crypto
|
|
|
- .createHash('md5')
|
|
|
- .update(userId + splitTestId)
|
|
|
- .digest('hex')
|
|
|
- const hashPrefix = hash.substr(0, 8)
|
|
|
- return Math.floor((parseInt(hashPrefix, 16) / 0xffffffff) * 100)
|
|
|
+function _getCachedVariantFromSession(session, splitTestName, currentVersion) {
|
|
|
+ if (!session.cachedSplitTestAssignments) {
|
|
|
+ session.cachedSplitTestAssignments = {}
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const cacheKey = `${splitTestName}-${currentVersion.versionNumber}`
|
|
|
+ if (currentVersion.active) {
|
|
|
+ return session.cachedSplitTestAssignments[cacheKey]
|
|
|
+ } else {
|
|
|
+ delete session.cachedSplitTestAssignments[cacheKey]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function _getUser(id) {
|
|
|
+ return UserGetter.promises.getUser(id, {
|
|
|
+ splitTests: 1,
|
|
|
+ alphaProgram: 1,
|
|
|
+ betaProgram: 1,
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
module.exports = {
|
|
|
- /**
|
|
|
- * @deprecated: use SplitTestV2Handler.getAssignment instead
|
|
|
- */
|
|
|
- getTestSegmentation: callbackify(getTestSegmentation),
|
|
|
+ getAssignment: callbackify(getAssignment),
|
|
|
+ assignInLocalsContext: callbackify(assignInLocalsContext),
|
|
|
promises: {
|
|
|
- /**
|
|
|
- * @deprecated: use SplitTestV2Handler.promises.getAssignment instead
|
|
|
- */
|
|
|
- getTestSegmentation,
|
|
|
+ getAssignment,
|
|
|
+ assignInLocalsContext,
|
|
|
},
|
|
|
}
|