import murmur from 'imurmurhash' import type { NextRouter } from 'next/router' import { CONTROL_VARIATION, EXPERIMENTS, ExperimentNames, TREATMENT_VARIATION, getActiveExperiments, } from './experiments' import { getUserEventsId } from '../events' import type { ParsedUrlQuery } from 'querystring' let experimentsInitialized = false let userIsStaff = false export function shouldShowExperiment( experimentKey: ExperimentNames | { key: ExperimentNames }, locale: string, version: string, isStaff: boolean, routerQuery: ParsedUrlQuery, ) { // Accept either EXPERIMENTS. or EXPERIMENTS..key if (typeof experimentKey === 'object') { experimentKey = experimentKey.key } // Determine if user is in treatment group. If they are, show the experiment const experiments = getActiveExperiments('all') for (const experiment of experiments) { if (experiment.key === experimentKey) { // If there is an override for the current session, use that if (controlGroupOverride[experiment.key]) { const controlGroup = getExperimentControlGroupFromSession( experimentKey, experiment.percentOfUsersToGetExperiment, ) return controlGroup === TREATMENT_VARIATION // Otherwise determine if the user is in the treatment group } else if ( (experiment.limitToLanguages?.length ? experiment.limitToLanguages.includes(locale) : true) && (experiment.limitToVersions?.length ? experiment.limitToVersions.includes(version) : true) ) { // If the user has staffonly cookie, and staff override is true, show the experiment if (experiment.alwaysShowForStaff) { if (isStaff) { userIsStaff = true console.log(`Staff cookie is set, showing '${experiment.key}' experiment`) return true } } if (experiment.turnOnWithURLParam) { if ( typeof routerQuery?.feature === 'string' ? routerQuery.feature.toLowerCase() === experiment.turnOnWithURLParam.toLowerCase() : false ) { return true } } return ( getExperimentControlGroupFromSession( experimentKey, experiment.percentOfUsersToGetExperiment, ) === TREATMENT_VARIATION ) } } } return false } // Allow developers to override their experiment group for the current session export const controlGroupOverride = {} as { [key in ExperimentNames]: 'treatment' | 'control' } if (typeof window !== 'undefined') { // @ts-expect-error globally available function window.overrideControlGroup = ( experimentKey: ExperimentNames, controlGroup: 'treatment' | 'control', ): string => { const activeExperiments = getActiveExperiments('all') // Make sure key is valid if (activeExperiments.some((experiment) => experiment.key === experimentKey)) { controlGroupOverride[experimentKey] = controlGroup const event = new Event('controlGroupOverrideChanged') window.dispatchEvent(event) return `Updated ${experimentKey}. Session is now in the "${controlGroup}" group for this session.` } else { throw new Error( `Invalid experiment key: ${experimentKey}. Must be one of: ${activeExperiments.map((experiment) => experiment.key).join(', ')}`, ) } } } // Determine if the user is in the treatment or control group for a given experiment export function getExperimentControlGroupFromSession( experimentKey: ExperimentNames, percentToGetExperiment = 50, ): string { if (controlGroupOverride[experimentKey]) { return controlGroupOverride[experimentKey] } else if (process.env.NODE_ENV === 'test') { return CONTROL_VARIATION } // We hash the user's events ID to ensure that the user is always in the same group for a given experiment // This works because the hash is a deterministic and the user's ID is stored in a cookie for 365 days const id = getUserEventsId() const hash = murmur(experimentKey).hash(id).result() const modHash = hash % 100 return modHash < percentToGetExperiment ? TREATMENT_VARIATION : CONTROL_VARIATION } export function getExperimentVariationForContext(locale: string, version: string): string { const experiments = getActiveExperiments(locale, version) for (const experiment of experiments) { if (experiment.includeVariationInContext) { // If the user is using the URL param to view the experiment, include the variation in the context if ( (experiment.turnOnWithURLParam && window.location?.search ?.toLowerCase() .includes(`feature=${experiment.turnOnWithURLParam.toLowerCase()}`)) || (experiment.alwaysShowForStaff && userIsStaff) ) { return TREATMENT_VARIATION } return getExperimentControlGroupFromSession( experiment.key, experiment.percentOfUsersToGetExperiment, ) } } // When no experiment has `includeVariationInContext: true` return CONTROL_VARIATION } export function initializeExperiments( locale: string, currentVersion: string, allVersions: { [key: string]: { version: string } }, ) { if (experimentsInitialized) return experimentsInitialized = true // Replace any occurrence of 'enterprise-server@latest' with the actual latest version for (const [experimentKey, experiment] of Object.entries(EXPERIMENTS)) { if (experiment.limitToVersions?.includes('enterprise-server@latest')) { // Sort the versions in descending order so that the latest enterprise-server version is first const latestEnterpriseServerVersion = Object.keys(allVersions) .filter((version) => version.startsWith('enterprise-server@')) .sort((a, b) => { const aVersion = a.split('@')[1] const bVersion = b.split('@')[1] return Number(bVersion) - Number(aVersion) })[0] if (latestEnterpriseServerVersion) { EXPERIMENTS[experimentKey as ExperimentNames].limitToVersions = experiment.limitToVersions.map((version) => version.replace( 'enterprise-server@latest', allVersions[latestEnterpriseServerVersion].version, ), ) } } } const experiments = getActiveExperiments(locale, currentVersion) let numberOfExperimentsUsingContext = 0 for (const experiment of experiments) { if (experiment.includeVariationInContext) { // Validate the experiments object numberOfExperimentsUsingContext++ if (numberOfExperimentsUsingContext > 1) { throw new Error( 'Only one experiment can include its variation in the context at a time. Please update the experiments configuration.', ) } } const controlGroup = getExperimentControlGroupFromSession( experiment.key, experiment.percentOfUsersToGetExperiment, ) // In any environment, it is useful to see if a given experiment is "on" or "off" console.log( `Experiment ${experiment.key} is in the "${controlGroup === TREATMENT_VARIATION ? TREATMENT_VARIATION : CONTROL_VARIATION}" group for this browser.\nCall function window.overrideControlGroup('${experiment.key}', 'treatment' | 'control') to change your group for this session.`, ) } } // If we have an experiment enabled that supports turnOnWithURLParam, we need to listen to // all clicks on links to ensure we forward the `feature` query param to the new page export function initializeForwardFeatureUrlParam(router: NextRouter, currentVersion: string) { const experiments = getActiveExperiments(router.locale || 'en', currentVersion) if (!experiments.some((experiment) => experiment.turnOnWithURLParam)) { return } try { const searchParams = new URLSearchParams(window.location.search) const featureValue = searchParams.get('feature') // If the user's URL doesn't include `feature`, we don't need to forward it if (!featureValue) return const updateAnchorHref = (anchor: HTMLAnchorElement): void => { try { const url = new URL(anchor.href, window.location.origin) url.searchParams.set('feature', featureValue) router.push(url.toString()) } catch (error) { console.error('Error modifying anchor URL:', error) router.push(anchor.href) } } const handleClick = (event: any) => { const anchor = event.target?.closest('a') if (anchor) { // If we found that the target is an anchor, we need to update and manually navigate to it event.preventDefault() updateAnchorHref(anchor) } } const handleKeyDown = (event: any) => { if (event.key !== 'Enter') return const anchor = event.target?.closest('a') if (anchor) { // If we found that the target is an anchor, we need to update and manually navigate to it event.preventDefault() updateAnchorHref(anchor) } } document.addEventListener('click', handleClick) document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('click', handleClick) document.removeEventListener('keydown', handleKeyDown) } } catch (error) { console.error('Error adding event listener:', error) } }