[web] avoid UserAnalyticsIdCache for split-test assignments (#34803)

* [web] split-tests: add helpers to using an already fetched mongo user

* [web] split-tests: add ignoreOverrides option to getAssignment

* [web] split-tests: reuse already fetched mongo users where possible

* [web] notifications: fetch users in bulk for split-test assignment

* [web] split-tests: use session for assignment where possible

* [web] split-tests: pass req/res down into domain-capture hook

* [web] split-tests: explicitly throw early

* [web] split-tests: refine jsdoc types

* [web] mark analyticsId and labsProgram as required in user model

* [web] split-tests: refine jsdoc types

* Revert "[web] split-tests: pass req/res down into domain-capture hook"

This reverts commit 897a158384cd2b3e416fc34495b20d1e2a0e68f0.

GitOrigin-RevId: ea4afd15c2506f369827b5193fd16d220d3c7d0e
This commit is contained in:
Jakob Ackermann
2026-06-23 08:06:42 +00:00
committed by Copybot
parent 6b084424b5
commit 1cc18dd0f0
17 changed files with 362 additions and 75 deletions
@@ -18,6 +18,7 @@ import SplitTestUserGetter from './SplitTestUserGetter.mjs'
/**
* @import { Assignment } from "./types"
* @import { SplitTestUser } from "./SplitTestUserGetter"
*/
const DEFAULT_VARIANT = 'default'
@@ -47,16 +48,17 @@ const DEFAULT_ASSIGNMENT = {
* @param req the request
* @param res the Express response object
* @param splitTestName the unique name of the split test
* @param {Object} options
* @param {boolean} options.sync - for test purposes only, to force the synchronous update of the user's profile
* @param {boolean} options.includeReferer For ajax requests and downloads include the split test overrides of the page
* @param {Object} [options]
* @param {boolean} [options.sync] - for test purposes only, to force the synchronous update of the user's profile
* @param {boolean} [options.includeReferer] For ajax requests and downloads include the split test overrides of the page
* @param {boolean} [options.ignoreOverrides] Ignore query-string variant overrides (e.g. for backend gating where the user must not be able to force a variant)
* @returns {Promise<Assignment>}
*/
async function getAssignment(
req,
res,
splitTestName,
{ sync = false, includeReferer = false } = {}
{ sync = false, includeReferer = false, ignoreOverrides = false } = {}
) {
let assignment
@@ -66,28 +68,30 @@ async function getAssignment(
} else {
await _loadSplitTestInfoInLocals(res.locals, splitTestName, req.session)
let query = req.query || {}
if (includeReferer && req.headers.referer) {
// Pick up the query of the top-level page, i.e. what's in the browsers address bar, from ajax requests.
// E.g. /project/:id?split-test=foo -> ajax /project/:id/compile should see split-test=foo.
// E.g. /project/:id?split-test=foo -> redirect /project/:id/download/zip should see split-test=foo.
try {
const u = new URL(req.headers.referer, Settings.siteUrl)
query = {
...Object.fromEntries(u.searchParams.entries()),
...query,
}
} catch {}
}
if (!ignoreOverrides) {
let query = req.query || {}
if (includeReferer && req.headers.referer) {
// Pick up the query of the top-level page, i.e. what's in the browsers address bar, from ajax requests.
// E.g. /project/:id?split-test=foo -> ajax /project/:id/compile should see split-test=foo.
// E.g. /project/:id?split-test=foo -> redirect /project/:id/download/zip should see split-test=foo.
try {
const u = new URL(req.headers.referer, Settings.siteUrl)
query = {
...Object.fromEntries(u.searchParams.entries()),
...query,
}
} catch {}
}
// 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,
metadata: {},
// 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,
metadata: {},
}
}
}
}
@@ -151,6 +155,33 @@ async function getAssignmentForUser(
}
}
/**
* Get the assignment of a user to a split test from an already-fetched mongo user.
*
* The user must include all the relevant fields. Unless you fetch the full user record, add `SplitTestUserGetter.getProjection(splitTestName)` to the projection.
*
* @param {SplitTestUser} user an already-fetched mongo 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<Assignment>}
*/
async function getAssignmentForMongoUser(
user,
splitTestName,
{ sync = false } = {}
) {
const { userId, analyticsId } = _getIdsFromMongoUser(user) // throw outside the try/catch.
try {
if (!Features.hasFeature('saas')) {
return _getNonSaasAssignment(splitTestName)
}
return _getAssignment(splitTestName, { analyticsId, userId, user, sync })
} catch (error) {
logger.error({ err: error }, 'Failed to get split test assignment for user')
return DEFAULT_ASSIGNMENT
}
}
/**
* Returns true if user has already been explicitly assigned to a variant.
* This will be false if the user **would** be assigned when calling getAssignment but hasn't yet.
@@ -259,13 +290,33 @@ async function getActiveAssignmentsForUser(
return {}
}
return getActiveAssignmentsForMongoUser(user, removeArchived, ignoreVersion)
}
/**
* Get a mapping of the active split test assignments from an already-fetched mongo user, avoiding a re-fetch. This should be the full user record.
* @param {SplitTestUser} user
* @param {boolean} removeArchived
* @param {boolean} ignoreVersion
*/
async function getActiveAssignmentsForMongoUser(
user,
removeArchived = false,
ignoreVersion = false
) {
if (!Features.hasFeature('saas')) {
return {}
}
const { analyticsId } = _getIdsFromMongoUser(user) // throw early.
const splitTests = (await SplitTestCache.get('')).values()
const assignments = {}
for (const splitTest of splitTests) {
if (!splitTest.versions[splitTest.versions.length - 1].active) continue
if (removeArchived && splitTest.archived) continue
const { activeForUser, selectedVariantName, phase, versionNumber } =
await _getAssignmentMetadata(user.analyticsId, user, splitTest)
await _getAssignmentMetadata(analyticsId, user, splitTest)
if (activeForUser) {
const assignment = {
variantName: selectedVariantName,
@@ -371,6 +422,20 @@ async function featureFlagEnabledForUser(userId, splitTestName) {
return variant === 'enabled'
}
/**
* Checks if a feature flag is enabled from an already-fetched mongo user
*
* See getAssignmentForMongoUser for details on the user.
*
* @param {SplitTestUser} user an already-fetched mongo user
* @param {string} splitTestName - The unique name of the feature flag
* @returns {Promise<boolean>} True if the user's assigned variant is 'enabled', false otherwise
*/
async function featureFlagEnabledForMongoUser(user, splitTestName) {
const { variant } = await getAssignmentForMongoUser(user, splitTestName)
return variant === 'enabled'
}
/**
* Returns an array of valid variant names for the given split test, including default
*
@@ -388,6 +453,28 @@ async function _getVariantNames(splitTestName) {
}
}
/**
* Extract the ids needed for a split test assignment from an already-fetched
* mongo user, throwing if a required field is missing from the projection.
*
* Only the ids are validated: the program/`splitTests` fields are read with
* optional chaining and a missing value is a legitimate "not enrolled" state.
*
* @param {SplitTestUser} user
* @return {{userId: string, analyticsId: string}}
*/
function _getIdsFromMongoUser(user) {
const userId = user?._id?.toString()
if (!userId) {
throw new Error('bug: include db.users._id in projection')
}
const analyticsId = user?.analyticsId
if (!analyticsId) {
throw new Error('bug: include db.users.analyticsId in projection')
}
return { userId, analyticsId }
}
async function _getAssignment(
splitTestName,
{ analyticsId, user, userId, session, sync }
@@ -749,7 +836,7 @@ async function _recordAssignment({
* @param {string} splitTestName - The name of the split test
* @param {string} variantName - The name of the variant
* @param {string} phase - The phase of the split test
* @param {Object} user - The user object
* @param {SplitTestUser} user - The user object
* @returns {Promise<boolean>} Whether the counter should be incremented
*/
async function _shouldIncrementVariantCounter(
@@ -1042,20 +1129,28 @@ export default {
getPercentile,
getAssignment: callbackify(getAssignment),
getAssignmentForUser: callbackify(getAssignmentForUser),
getAssignmentForMongoUser: callbackify(getAssignmentForMongoUser),
featureFlagEnabled: callbackify(featureFlagEnabled),
featureFlagEnabledForUser: callbackify(featureFlagEnabledForUser),
featureFlagEnabledForMongoUser: callbackify(featureFlagEnabledForMongoUser),
getOneTimeAssignment: callbackify(getOneTimeAssignment),
getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
getActiveAssignmentsForMongoUser: callbackify(
getActiveAssignmentsForMongoUser
),
hasUserBeenAssignedToVariant: callbackify(hasUserBeenAssignedToVariant),
setOverrideInSession,
clearOverridesInSession,
promises: {
getAssignment,
getAssignmentForUser,
getAssignmentForMongoUser,
featureFlagEnabled,
featureFlagEnabledForUser,
featureFlagEnabledForMongoUser,
getOneTimeAssignment,
getActiveAssignmentsForUser,
getActiveAssignmentsForMongoUser,
hasUserBeenAssignedToVariant,
decrementLabsVariantCounter,
incrementLabsVariantCounterIfBelowLimit,