| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516 |
- const Metrics = require('@overleaf/metrics')
- const UserGetter = require('../User/UserGetter')
- const UserUpdater = require('../User/UserUpdater')
- const AnalyticsManager = require('../Analytics/AnalyticsManager')
- const LocalsHelper = require('./LocalsHelper')
- const crypto = require('crypto')
- const _ = require('lodash')
- const { callbackify } = require('util')
- const SplitTestCache = require('./SplitTestCache')
- const { SplitTest } = require('../../models/SplitTest')
- const UserAnalyticsIdCache = require('../Analytics/UserAnalyticsIdCache')
- const { getAnalyticsIdFromMongoUser } = require('../Analytics/AnalyticsHelper')
- const Features = require('../../infrastructure/Features')
- const SplitTestUtils = require('./SplitTestUtils')
- const Settings = require('@overleaf/settings')
- const DEFAULT_VARIANT = 'default'
- const ALPHA_PHASE = 'alpha'
- const BETA_PHASE = 'beta'
- const CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER = null
- const DEFAULT_ASSIGNMENT = {
- variant: DEFAULT_VARIANT,
- analytics: {
- segmentation: {},
- },
- }
- /**
- * Get the assignment of a user to a split test and store it in the response locals context
- *
- * @example
- * // Assign user and record an event
- *
- * const assignment = await SplitTestHandler.getAssignment(req, res, '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 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<{variant: string, analytics: {segmentation: {splitTest: string, variant: string, phase: string, versionNumber: number}|{}}}>}
- */
- async function getAssignment(req, res, splitTestName, { sync = false } = {}) {
- const query = req.query || {}
- let assignment
- if (!Features.hasFeature('saas')) {
- assignment = _getNonSaasAssignment(splitTestName)
- } else {
- // Check the query string for an override, ignoring an invalid value
- const queryVariant = query[splitTestName]
- if (queryVariant) {
- const variants = await _getVariantNames(splitTestName)
- if (variants.includes(queryVariant)) {
- assignment = {
- variant: queryVariant,
- analytics: {
- segmentation: {},
- },
- }
- }
- }
- if (!assignment) {
- const { userId, analyticsId } = AnalyticsManager.getIdsFromSession(
- req.session
- )
- assignment = await _getAssignment(splitTestName, {
- analyticsId,
- userId,
- session: req.session,
- sync,
- })
- _collectSessionStats(req.session)
- }
- }
- LocalsHelper.setSplitTestVariant(
- res.locals,
- splitTestName,
- assignment.variant
- )
- await _loadSplitTestInfoInLocals(res.locals, splitTestName)
- return assignment
- }
- /**
- * Get the assignment of a user to a split test by their user ID.
- *
- * Warning: this does not support query parameters override, nor makes the assignment and split test info available to
- * the frontend through locals. Wherever possible, `getAssignment` should be used instead.
- *
- * @param userId the user ID
- * @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 getAssignmentForUser(
- userId,
- splitTestName,
- { sync = false } = {}
- ) {
- if (!Features.hasFeature('saas')) {
- return _getNonSaasAssignment(splitTestName)
- }
- const analyticsId = await UserAnalyticsIdCache.get(userId)
- return _getAssignment(splitTestName, { analyticsId, userId, sync })
- }
- /**
- * Get the assignment of a user to a split test by their pre-fetched mongo doc.
- *
- * Warning: this does not support query parameters override, nor makes the assignment and split test info available to
- * the frontend through locals. Wherever possible, `getAssignment` should be used instead.
- *
- * @param user the user
- * @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 getAssignmentForMongoUser(
- user,
- splitTestName,
- { sync = false } = {}
- ) {
- if (!Features.hasFeature('saas')) {
- return _getNonSaasAssignment(splitTestName)
- }
- return _getAssignment(splitTestName, {
- analyticsId: getAnalyticsIdFromMongoUser(user),
- sync,
- user,
- userId: user._id.toString(),
- })
- }
- /**
- * Get a mapping of the active split test assignments for the given user
- */
- async function getActiveAssignmentsForUser(userId, removeArchived = false) {
- if (!Features.hasFeature('saas')) {
- return {}
- }
- const user = await _getUser(userId)
- if (user == null) {
- return {}
- }
- const splitTests = await SplitTest.find({
- $where: 'this.versions[this.versions.length - 1].active',
- ...(removeArchived && { archived: { $ne: true } }),
- }).exec()
- const assignments = {}
- for (const splitTest of splitTests) {
- const { activeForUser, selectedVariantName, phase, versionNumber } =
- await _getAssignmentMetadata(user.analyticsId, user, splitTest)
- if (activeForUser) {
- const assignment = {
- variantName: selectedVariantName,
- versionNumber,
- phase,
- }
- const userAssignments = user.splitTests?.[splitTest.name]
- if (Array.isArray(userAssignments)) {
- const userAssignment = userAssignments.find(
- x => x.versionNumber === versionNumber
- )
- if (userAssignment) {
- assignment.assignedAt = userAssignment.assignedAt
- }
- }
- assignments[splitTest.name] = assignment
- }
- }
- return assignments
- }
- /**
- * Returns an array of valid variant names for the given split test, including default
- *
- * @param splitTestName
- * @returns {Promise<string[]>}
- * @private
- */
- async function _getVariantNames(splitTestName) {
- const splitTest = await SplitTestCache.get(splitTestName)
- const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
- if (currentVersion?.active) {
- return currentVersion.variants.map(v => v.name).concat([DEFAULT_VARIANT])
- } else {
- return [DEFAULT_VARIANT]
- }
- }
- async function _getAssignment(
- splitTestName,
- { analyticsId, user, userId, session, sync }
- ) {
- if (!analyticsId && !userId) {
- return DEFAULT_ASSIGNMENT
- }
- const splitTest = await SplitTestCache.get(splitTestName)
- const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
- if (!currentVersion?.active) {
- return DEFAULT_ASSIGNMENT
- }
- if (session) {
- const cachedVariant = _getCachedVariantFromSession(
- session,
- splitTest.name,
- currentVersion
- )
- if (cachedVariant === CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER) {
- Metrics.inc('split_test_get_assignment_source', 1, { status: 'cache' })
- return DEFAULT_ASSIGNMENT
- }
- if (cachedVariant) {
- Metrics.inc('split_test_get_assignment_source', 1, { status: 'cache' })
- return _makeAssignment(splitTest, cachedVariant, currentVersion)
- }
- }
- if (user) {
- Metrics.inc('split_test_get_assignment_source', 1, { status: 'provided' })
- } else if (userId) {
- Metrics.inc('split_test_get_assignment_source', 1, { status: 'mongo' })
- } else {
- Metrics.inc('split_test_get_assignment_source', 1, { status: 'none' })
- }
- user = user || (userId && (await _getUser(userId, splitTestName)))
- const { activeForUser, selectedVariantName, phase, versionNumber } =
- await _getAssignmentMetadata(analyticsId, user, splitTest)
- if (session) {
- _setVariantInSession({
- session,
- splitTestName,
- currentVersion,
- selectedVariantName,
- activeForUser,
- })
- }
- if (activeForUser) {
- const assignmentConfig = {
- user,
- userId,
- analyticsId,
- session,
- splitTestName,
- variantName: selectedVariantName,
- phase,
- versionNumber,
- }
- if (currentVersion.analyticsEnabled) {
- if (sync === true) {
- await _updateVariantAssignment(assignmentConfig)
- } else {
- _updateVariantAssignment(assignmentConfig)
- }
- }
- return _makeAssignment(splitTest, selectedVariantName, currentVersion)
- }
- return DEFAULT_ASSIGNMENT
- }
- async function _getAssignmentMetadata(analyticsId, user, splitTest) {
- const currentVersion = SplitTestUtils.getCurrentVersion(splitTest)
- const phase = currentVersion.phase
- if (
- (phase === ALPHA_PHASE && !user?.alphaProgram) ||
- (phase === BETA_PHASE && !user?.betaProgram)
- ) {
- return {
- activeForUser: false,
- }
- }
- const userId = user?._id.toString()
- const percentile = getPercentile(analyticsId || userId, splitTest.name, phase)
- const selectedVariantName = _getVariantFromPercentile(
- currentVersion.variants,
- percentile
- )
- return {
- activeForUser: true,
- selectedVariantName: selectedVariantName || DEFAULT_VARIANT,
- phase,
- versionNumber: currentVersion.versionNumber,
- }
- }
- 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
- }
- }
- }
- }
- async function _updateVariantAssignment({
- user,
- userId,
- analyticsId,
- session,
- splitTestName,
- phase,
- versionNumber,
- variantName,
- }) {
- const persistedAssignment = {
- variantName,
- versionNumber,
- phase,
- assignedAt: new Date(),
- }
- // if the user is logged in
- if (userId) {
- user = user || (await _getUser(userId, splitTestName))
- 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(
- user.analyticsId || analyticsId || userId,
- `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
- )
- }
- }
- }
- function _makeAssignment(splitTest, variant, currentVersion) {
- return {
- variant,
- analytics: {
- segmentation: {
- splitTest: splitTest.name,
- variant,
- phase: currentVersion.phase,
- versionNumber: currentVersion.versionNumber,
- },
- },
- }
- }
- function _getCachedVariantFromSession(session, splitTestName, currentVersion) {
- if (!session.cachedSplitTestAssignments) {
- session.cachedSplitTestAssignments = {}
- }
- const cacheKey = `${splitTestName}-${currentVersion.versionNumber}`
- return session.cachedSplitTestAssignments[cacheKey]
- }
- function _setVariantInSession({
- session,
- splitTestName,
- currentVersion,
- selectedVariantName,
- activeForUser,
- }) {
- if (!session.cachedSplitTestAssignments) {
- session.cachedSplitTestAssignments = {}
- }
- // clean up previous entries from this split test
- for (const cacheKey of Object.keys(session.cachedSplitTestAssignments)) {
- // drop '-versionNumber'
- const name = cacheKey.split('-').slice(0, -1).join('-')
- if (name === splitTestName) {
- delete session.cachedSplitTestAssignments[cacheKey]
- }
- }
- const cacheKey = `${splitTestName}-${currentVersion.versionNumber}`
- if (activeForUser) {
- session.cachedSplitTestAssignments[cacheKey] = selectedVariantName
- } else {
- session.cachedSplitTestAssignments[cacheKey] =
- CACHE_TOMBSTONE_SPLIT_TEST_NOT_ACTIVE_FOR_USER
- }
- }
- async function _getUser(id, splitTestName) {
- const projection = {
- analyticsId: 1,
- alphaProgram: 1,
- betaProgram: 1,
- }
- if (splitTestName) {
- projection[`splitTests.${splitTestName}`] = 1
- } else {
- projection.splitTests = 1
- }
- const user = await UserGetter.promises.getUser(id, projection)
- Metrics.histogram(
- 'split_test_get_user_from_mongo_size',
- JSON.stringify(user).length,
- [0, 100, 500, 1000, 2000, 5000, 10000, 15000, 20000, 50000, 100000]
- )
- return user
- }
- async function _loadSplitTestInfoInLocals(locals, splitTestName) {
- const splitTest = await SplitTestCache.get(splitTestName)
- if (splitTest) {
- const phase = SplitTestUtils.getCurrentVersion(splitTest).phase
- LocalsHelper.setSplitTestInfo(locals, splitTestName, {
- phase,
- badgeInfo: splitTest.badgeInfo?.[phase],
- })
- }
- }
- function _getNonSaasAssignment(splitTestName) {
- if (Settings.splitTestOverrides?.[splitTestName]) {
- return {
- variant: Settings.splitTestOverrides?.[splitTestName],
- analytics: {
- segmentation: {},
- },
- }
- }
- return DEFAULT_ASSIGNMENT
- }
- function _collectSessionStats(session) {
- if (session.cachedSplitTestAssignments) {
- Metrics.summary(
- 'split_test_session_cache_count',
- Object.keys(session.cachedSplitTestAssignments).length
- )
- Metrics.summary(
- 'split_test_session_cache_size',
- JSON.stringify(session.cachedSplitTestAssignments).length
- )
- }
- if (session.splitTests) {
- Metrics.summary(
- 'split_test_session_storage_count',
- Object.keys(session.splitTests).length
- )
- Metrics.summary(
- 'split_test_session_storage_size',
- JSON.stringify(session.splitTests).length
- )
- }
- }
- module.exports = {
- getPercentile,
- getAssignment: callbackify(getAssignment),
- getAssignmentForMongoUser: callbackify(getAssignmentForMongoUser),
- getAssignmentForUser: callbackify(getAssignmentForUser),
- getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
- promises: {
- getAssignment,
- getAssignmentForMongoUser,
- getAssignmentForUser,
- getActiveAssignmentsForUser,
- },
- }
|