Merge pull request #19053 from overleaf/ab-split-tests-first-time-assignments

[web] Return isFirstTimeAssignment flag with split test assignments

GitOrigin-RevId: 70954470fbd9430749d83d8d1e08a3969d4a09e6
This commit is contained in:
Jimmy Domagala-Tang 2024-06-24 09:27:13 -04:00 committed by Copybot
parent 6db455d63e
commit 04432478e1
4 changed files with 104 additions and 39 deletions

View file

@ -41,6 +41,7 @@ const ProjectAuditLogHandler = require('./ProjectAuditLogHandler')
const PublicAccessLevels = require('../Authorization/PublicAccessLevels')
const TagsHandler = require('../Tags/TagsHandler')
const TutorialHandler = require('../Tutorial/TutorialHandler')
const UserUpdater = require('../User/UserUpdater')
/**
* @typedef {import("./types").GetProjectsRequest} GetProjectsRequest
@ -423,6 +424,30 @@ const _ProjectController = {
isInvitedMember,
} = userValues
// check if a user is not in the writefull-oauth-promotion, in which case they may be part of the auto trial group
if (
!anonymous &&
splitTestAssignments['writefull-oauth-promotion']?.variant === 'default'
) {
// since we are auto-enrolling users into writefull if they are part of the group, we only want to
// auto enroll (set writefull to true) if its the first time they have entered the test
// this ensures that they can still turn writefull off (otherwise, we would be setting writefull on every time they access their projects)
const { variant, metadata } =
await SplitTestHandler.promises.getAssignment(
req,
res,
'writefull-auto-load'
)
if (variant === 'enabled' && metadata?.isFirstNonDefaultAssignment) {
await UserUpdater.promises.updateUser(userId, {
$set: {
writefull: { enabled: true },
},
})
user.writefull.enabled = true
}
}
const brandVariation = project?.brandVariationId
? await BrandVariationsHandler.promises.getBrandVariationById(
project.brandVariationId

View file

@ -16,15 +16,17 @@ const logger = require('@overleaf/logger')
const SplitTestSessionHandler = require('./SplitTestSessionHandler')
const SplitTestUserGetter = require('./SplitTestUserGetter')
/**
* @typedef {import("./types").Assignment} Assignment
*/
const DEFAULT_VARIANT = 'default'
const ALPHA_PHASE = 'alpha'
const BETA_PHASE = 'beta'
const RELEASE_PHASE = 'release'
const DEFAULT_ASSIGNMENT = {
variant: DEFAULT_VARIANT,
analytics: {
segmentation: {},
},
metadata: {},
}
/**
@ -40,17 +42,12 @@ const DEFAULT_ASSIGNMENT = {
* 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}|{}}}>}
* @returns {Promise<Assignment>}
*/
async function getAssignment(req, res, splitTestName, { sync = false } = {}) {
const query = req.query || {}
@ -69,9 +66,7 @@ async function getAssignment(req, res, splitTestName, { sync = false } = {}) {
if (variants.includes(queryVariant)) {
assignment = {
variant: queryVariant,
analytics: {
segmentation: {},
},
metadata: {},
}
}
}
@ -112,7 +107,7 @@ async function getAssignment(req, res, splitTestName, { sync = false } = {}) {
* @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}|{}}}>}
* @returns {Promise<Assignment>}
*/
async function getAssignmentForUser(
userId,
@ -141,7 +136,7 @@ async function getAssignmentForUser(
* @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}|{}}}>}
* @returns {Promise<Assignment>}
*/
async function getAssignmentForMongoUser(
user,
@ -215,7 +210,7 @@ async function getActiveAssignmentsForUser(userId, removeArchived = false) {
* To be used only in cases where we need random assignments that are independent of a user or session.
* If the test is in alpha or beta phase, always returns the default variant.
* @param splitTestName
* @returns {Promise<{variant: string, analytics: {segmentation: {splitTest: string, variant: string, phase: string, versionNumber: number}|{}}}>}
* @returns {Promise<Assignment>}
*/
async function getOneTimeAssignment(splitTestName) {
try {
@ -239,7 +234,13 @@ async function getOneTimeAssignment(splitTestName) {
undefined,
splitTest
)
return _makeAssignment(splitTest, selectedVariantName, currentVersion)
return _makeAssignment({
variant: selectedVariantName,
currentVersion,
isFirstNonDefaultAssignment:
selectedVariantName !== DEFAULT_VARIANT &&
currentVersion.analyticsEnabled,
})
} catch (error) {
logger.error({ err: error }, 'Failed to get one time split test assignment')
return DEFAULT_ASSIGNMENT
@ -277,7 +278,7 @@ async function _getAssignment(
if (Settings.devToolbar.enabled) {
const override = session?.splitTestOverrides?.[splitTestName]
if (override) {
return _makeAssignment(splitTest, override, currentVersion)
return _makeAssignment({ variant: override, currentVersion })
}
}
@ -308,7 +309,11 @@ async function _getAssignment(
) {
return DEFAULT_ASSIGNMENT
} else {
return _makeAssignment(splitTest, cachedVariant, currentVersion)
return _makeAssignment({
variant: cachedVariant,
currentVersion,
isFirstNonDefaultAssignment: false,
})
}
}
}
@ -325,8 +330,9 @@ async function _getAssignment(
user ||
(userId &&
(await SplitTestUserGetter.promises.getUser(userId, splitTestName)))
const { activeForUser, selectedVariantName, phase, versionNumber } =
await _getAssignmentMetadata(analyticsId, user, splitTest)
const metadata = await _getAssignmentMetadata(analyticsId, user, splitTest)
const { activeForUser, selectedVariantName, phase, versionNumber } = metadata
if (canUseSessionCache) {
SplitTestSessionHandler.setVariantInCache({
session,
@ -336,6 +342,7 @@ async function _getAssignment(
activeForUser,
})
}
if (activeForUser) {
if (currentVersion.analyticsEnabled) {
// if the user is logged in, persist the assignment
@ -396,7 +403,20 @@ async function _getAssignment(
)
})
}
return _makeAssignment(splitTest, selectedVariantName, currentVersion)
let isFirstNonDefaultAssignment
if (userId) {
isFirstNonDefaultAssignment = metadata.isFirstNonDefaultAssignment
} else {
const assignments =
await SplitTestSessionHandler.promises.getAssignments(session)
isFirstNonDefaultAssignment = !assignments?.[splitTestName]
}
return _makeAssignment({
variant: selectedVariantName,
currentVersion,
isFirstNonDefaultAssignment,
})
}
return DEFAULT_ASSIGNMENT
@ -415,15 +435,21 @@ async function _getAssignmentMetadata(analyticsId, user, splitTest) {
}
const userId = user?._id.toString()
const percentile = getPercentile(analyticsId || userId, splitTest.name, phase)
const selectedVariantName = _getVariantFromPercentile(
currentVersion.variants,
percentile
)
const selectedVariantName =
_getVariantFromPercentile(currentVersion.variants, percentile) ||
DEFAULT_VARIANT
return {
activeForUser: true,
selectedVariantName: selectedVariantName || DEFAULT_VARIANT,
selectedVariantName,
phase,
versionNumber: currentVersion.versionNumber,
isFirstNonDefaultAssignment:
selectedVariantName !== DEFAULT_VARIANT &&
currentVersion.analyticsEnabled &&
(!Array.isArray(user?.splitTests?.[splitTest.name]) ||
!user?.splitTests?.[splitTest.name]?.some(
assignment => assignment.variantName !== DEFAULT_VARIANT
)),
}
}
@ -492,18 +518,17 @@ async function _recordAssignment({
}
}
function _makeAssignment(splitTest, variant, currentVersion) {
function _makeAssignment({
variant,
currentVersion,
isFirstNonDefaultAssignment,
}) {
return {
variant,
analytics: {
segmentation: splitTest
? {
splitTest: splitTest.name,
variant,
phase: currentVersion.phase,
versionNumber: currentVersion.versionNumber,
}
: {},
metadata: {
phase: currentVersion.phase,
versionNumber: currentVersion.versionNumber,
isFirstNonDefaultAssignment,
},
}
}
@ -543,9 +568,7 @@ function _getNonSaasAssignment(splitTestName) {
if (Settings.splitTestOverrides?.[splitTestName]) {
return {
variant: Settings.splitTestOverrides?.[splitTestName],
analytics: {
segmentation: {},
},
metadata: {},
}
}
return DEFAULT_ASSIGNMENT

View file

@ -0,0 +1,12 @@
type AssignmentMetadata = {
phase?: 'alpha' | 'beta' | 'release'
versionNumber?: boolean
// only returned when `analyticsEnabled` is set to `true` on the current version
// of the split test, and an assignment is queried for the user for the first time
isFirstNonDefaultAssignment?: boolean
}
export type Assignment = {
variant: string
metadata: AssignmentMetadata
}

View file

@ -239,6 +239,11 @@ describe('ProjectController', function () {
'../Survey/SurveyHandler': this.SurveyHandler,
'./ProjectAuditLogHandler': this.ProjectAuditLogHandler,
'../Tutorial/TutorialHandler': this.TutorialHandler,
'../User/UserUpdater': {
promises: {
updateUser: sinon.stub().resolves(),
},
},
},
})