|
@@ -18,6 +18,7 @@ import SplitTestUserGetter from './SplitTestUserGetter.mjs'
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* @import { Assignment } from "./types"
|
|
* @import { Assignment } from "./types"
|
|
|
|
|
+ * @import { SplitTestUser } from "./SplitTestUserGetter"
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
const DEFAULT_VARIANT = 'default'
|
|
const DEFAULT_VARIANT = 'default'
|
|
@@ -47,16 +48,17 @@ const DEFAULT_ASSIGNMENT = {
|
|
|
* @param req the request
|
|
* @param req the request
|
|
|
* @param res the Express response object
|
|
* @param res the Express response object
|
|
|
* @param splitTestName the unique name of the split test
|
|
* @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>}
|
|
* @returns {Promise<Assignment>}
|
|
|
*/
|
|
*/
|
|
|
async function getAssignment(
|
|
async function getAssignment(
|
|
|
req,
|
|
req,
|
|
|
res,
|
|
res,
|
|
|
splitTestName,
|
|
splitTestName,
|
|
|
- { sync = false, includeReferer = false } = {}
|
|
|
|
|
|
|
+ { sync = false, includeReferer = false, ignoreOverrides = false } = {}
|
|
|
) {
|
|
) {
|
|
|
let assignment
|
|
let assignment
|
|
|
|
|
|
|
@@ -66,28 +68,30 @@ async function getAssignment(
|
|
|
} else {
|
|
} else {
|
|
|
await _loadSplitTestInfoInLocals(res.locals, splitTestName, req.session)
|
|
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.
|
|
* 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.
|
|
* 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 {}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ 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 splitTests = (await SplitTestCache.get('')).values()
|
|
|
const assignments = {}
|
|
const assignments = {}
|
|
|
for (const splitTest of splitTests) {
|
|
for (const splitTest of splitTests) {
|
|
|
if (!splitTest.versions[splitTest.versions.length - 1].active) continue
|
|
if (!splitTest.versions[splitTest.versions.length - 1].active) continue
|
|
|
if (removeArchived && splitTest.archived) continue
|
|
if (removeArchived && splitTest.archived) continue
|
|
|
const { activeForUser, selectedVariantName, phase, versionNumber } =
|
|
const { activeForUser, selectedVariantName, phase, versionNumber } =
|
|
|
- await _getAssignmentMetadata(user.analyticsId, user, splitTest)
|
|
|
|
|
|
|
+ await _getAssignmentMetadata(analyticsId, user, splitTest)
|
|
|
if (activeForUser) {
|
|
if (activeForUser) {
|
|
|
const assignment = {
|
|
const assignment = {
|
|
|
variantName: selectedVariantName,
|
|
variantName: selectedVariantName,
|
|
@@ -371,6 +422,20 @@ async function featureFlagEnabledForUser(userId, splitTestName) {
|
|
|
return variant === 'enabled'
|
|
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
|
|
* 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(
|
|
async function _getAssignment(
|
|
|
splitTestName,
|
|
splitTestName,
|
|
|
{ analyticsId, user, userId, session, sync }
|
|
{ analyticsId, user, userId, session, sync }
|
|
@@ -749,7 +836,7 @@ async function _recordAssignment({
|
|
|
* @param {string} splitTestName - The name of the split test
|
|
* @param {string} splitTestName - The name of the split test
|
|
|
* @param {string} variantName - The name of the variant
|
|
* @param {string} variantName - The name of the variant
|
|
|
* @param {string} phase - The phase of the split test
|
|
* @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
|
|
* @returns {Promise<boolean>} Whether the counter should be incremented
|
|
|
*/
|
|
*/
|
|
|
async function _shouldIncrementVariantCounter(
|
|
async function _shouldIncrementVariantCounter(
|
|
@@ -1042,20 +1129,28 @@ export default {
|
|
|
getPercentile,
|
|
getPercentile,
|
|
|
getAssignment: callbackify(getAssignment),
|
|
getAssignment: callbackify(getAssignment),
|
|
|
getAssignmentForUser: callbackify(getAssignmentForUser),
|
|
getAssignmentForUser: callbackify(getAssignmentForUser),
|
|
|
|
|
+ getAssignmentForMongoUser: callbackify(getAssignmentForMongoUser),
|
|
|
featureFlagEnabled: callbackify(featureFlagEnabled),
|
|
featureFlagEnabled: callbackify(featureFlagEnabled),
|
|
|
featureFlagEnabledForUser: callbackify(featureFlagEnabledForUser),
|
|
featureFlagEnabledForUser: callbackify(featureFlagEnabledForUser),
|
|
|
|
|
+ featureFlagEnabledForMongoUser: callbackify(featureFlagEnabledForMongoUser),
|
|
|
getOneTimeAssignment: callbackify(getOneTimeAssignment),
|
|
getOneTimeAssignment: callbackify(getOneTimeAssignment),
|
|
|
getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
|
|
getActiveAssignmentsForUser: callbackify(getActiveAssignmentsForUser),
|
|
|
|
|
+ getActiveAssignmentsForMongoUser: callbackify(
|
|
|
|
|
+ getActiveAssignmentsForMongoUser
|
|
|
|
|
+ ),
|
|
|
hasUserBeenAssignedToVariant: callbackify(hasUserBeenAssignedToVariant),
|
|
hasUserBeenAssignedToVariant: callbackify(hasUserBeenAssignedToVariant),
|
|
|
setOverrideInSession,
|
|
setOverrideInSession,
|
|
|
clearOverridesInSession,
|
|
clearOverridesInSession,
|
|
|
promises: {
|
|
promises: {
|
|
|
getAssignment,
|
|
getAssignment,
|
|
|
getAssignmentForUser,
|
|
getAssignmentForUser,
|
|
|
|
|
+ getAssignmentForMongoUser,
|
|
|
featureFlagEnabled,
|
|
featureFlagEnabled,
|
|
|
featureFlagEnabledForUser,
|
|
featureFlagEnabledForUser,
|
|
|
|
|
+ featureFlagEnabledForMongoUser,
|
|
|
getOneTimeAssignment,
|
|
getOneTimeAssignment,
|
|
|
getActiveAssignmentsForUser,
|
|
getActiveAssignmentsForUser,
|
|
|
|
|
+ getActiveAssignmentsForMongoUser,
|
|
|
hasUserBeenAssignedToVariant,
|
|
hasUserBeenAssignedToVariant,
|
|
|
decrementLabsVariantCounter,
|
|
decrementLabsVariantCounter,
|
|
|
incrementLabsVariantCounterIfBelowLimit,
|
|
incrementLabsVariantCounterIfBelowLimit,
|