@omniconvert/server-side-testing-sdk
Version:
TypeScript SDK for server-side A/B testing and experimentation
1,385 lines (1,370 loc) • 277 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.OmniconvertSDK = {}));
})(this, (function (exports) { 'use strict';
/**
* Browser logger implementation
* Provides structured logging for browser environments
*/
class BrowserLogger {
constructor(logLevel = 'info', maxLogs = 1000, outputToConsole = false) {
this.logs = [];
this.maxLogs = 1000;
this.logLevel = 'info';
this.outputToConsole = false;
this.logLevel = logLevel;
this.maxLogs = maxLogs;
this.outputToConsole = outputToConsole;
}
/**
* Log debug message
*/
debug(message, context) {
this.log('debug', message, context);
}
/**
* Log info message
*/
info(message, context) {
this.log('info', message, context);
}
/**
* Log notice message
*/
notice(message, context) {
this.log('notice', message, context);
}
/**
* Log warning message
*/
warning(message, context) {
this.log('warning', message, context);
}
/**
* Log error message
*/
error(message, context) {
this.log('error', message, context);
}
/**
* Core logging method
*/
log(level, message, context) {
const entry = {
level,
message,
context,
timestamp: new Date(),
id: this.generateLogId(),
};
// Add to internal log storage
this.logs.push(entry);
// Trim logs if over limit
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
// Output to browser console if level is appropriate and console output is enabled
if (this.outputToConsole && this.shouldLog(level)) {
this.outputToBrowserConsole(entry);
}
}
/**
* Check if log level should be output
*/
shouldLog(level) {
const levels = ['debug', 'info', 'notice', 'warning', 'error'];
const currentLevelIndex = levels.indexOf(this.logLevel);
const messageLevelIndex = levels.indexOf(level);
return messageLevelIndex >= currentLevelIndex;
}
/**
* Output log entry to browser console
*/
outputToBrowserConsole(entry) {
const prefix = `[${entry.timestamp.toISOString()}] [${entry.level.toUpperCase()}]`;
const message = `${prefix} ${entry.message}`;
switch (entry.level) {
case 'debug':
if (entry.context) {
console.debug(message, entry.context);
}
else {
console.debug(message);
}
break;
case 'info':
case 'notice':
if (entry.context) {
console.info(message, entry.context);
}
else {
console.info(message);
}
break;
case 'warning':
if (entry.context) {
console.warn(message, entry.context);
}
else {
console.warn(message);
}
break;
case 'error':
if (entry.context) {
console.error(message, entry.context);
}
else {
console.error(message);
}
break;
}
}
/**
* Get all logs
*/
getLogs(filterLevel) {
if (!filterLevel) {
return [...this.logs];
}
return this.logs.filter(log => log.level === filterLevel);
}
/**
* Get logs by level priority
*/
getLogsByPriority(minLevel) {
const levels = ['debug', 'info', 'notice', 'warning', 'error'];
const minLevelIndex = levels.indexOf(minLevel);
return this.logs.filter(log => {
const logLevelIndex = levels.indexOf(log.level);
return logLevelIndex >= minLevelIndex;
});
}
/**
* Clear all logs
*/
clearLogs() {
this.logs = [];
}
/**
* Set log level
*/
setLogLevel(level) {
this.logLevel = level;
}
/**
* Get current log level
*/
getLogLevel() {
return this.logLevel;
}
/**
* Set maximum number of logs to keep
*/
setMaxLogs(maxLogs) {
this.maxLogs = maxLogs;
// Trim existing logs if necessary
if (this.logs.length > maxLogs) {
this.logs = this.logs.slice(-maxLogs);
}
}
/**
* Get maximum number of logs
*/
getMaxLogs() {
return this.maxLogs;
}
/**
* Export logs as JSON
*/
exportLogs() {
return JSON.stringify(this.logs, null, 2);
}
/**
* Get log statistics
*/
getLogStats() {
const stats = {
total: this.logs.length,
debug: 0,
info: 0,
notice: 0,
warning: 0,
error: 0,
};
this.logs.forEach(log => {
stats[log.level]++;
});
return stats;
}
/**
* Enable/disable console output
*/
setConsoleOutput(enabled) {
this.outputToConsole = enabled;
}
/**
* Check if console output is enabled
*/
isConsoleOutputEnabled() {
return this.outputToConsole;
}
/**
* Generate unique log ID
*/
generateLogId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
}
/**
* Logger factory for creating and managing logger instances
* Provides a singleton logger for the entire SDK
*/
class LoggerFactory {
/**
* Get the singleton logger instance
*/
static getLogger(outputToConsole = false) {
if (!LoggerFactory.instance) {
LoggerFactory.instance = new BrowserLogger(LoggerFactory.defaultLogLevel, 1000, outputToConsole);
}
return LoggerFactory.instance;
}
/**
* Set a custom logger instance
*/
static setLogger(logger) {
LoggerFactory.instance = logger;
}
/**
* Create a new logger instance (without setting as singleton)
*/
static createLogger(logLevel = 'info', maxLogs = 1000, outputToConsole = false) {
return new BrowserLogger(logLevel, maxLogs, outputToConsole);
}
/**
* Set the default log level for new loggers
*/
static setDefaultLogLevel(level) {
LoggerFactory.defaultLogLevel = level;
// Update existing instance if it exists
if (LoggerFactory.instance) {
LoggerFactory.instance.setLogLevel(level);
}
}
/**
* Get the default log level
*/
static getDefaultLogLevel() {
return LoggerFactory.defaultLogLevel;
}
/**
* Reset the logger factory (clear singleton)
*/
static reset() {
LoggerFactory.instance = null;
}
/**
* Configure logger for development environment
*/
static configureForDevelopment() {
LoggerFactory.setDefaultLogLevel('debug');
const logger = LoggerFactory.getLogger(true); // Enable console output for development
logger.setMaxLogs(5000); // More logs for development
logger.setConsoleOutput(true);
}
/**
* Configure logger for production environment
*/
static configureForProduction() {
LoggerFactory.setDefaultLogLevel('warning');
const logger = LoggerFactory.getLogger(false); // Disable console output for production
logger.setMaxLogs(500); // Fewer logs for production
logger.setConsoleOutput(false);
}
/**
* Check if logging is enabled for a specific level
*/
static isLevelEnabled(level) {
const logger = LoggerFactory.getLogger();
const levels = ['debug', 'info', 'notice', 'warning', 'error'];
const currentLevelIndex = levels.indexOf(logger.getLogLevel());
const checkLevelIndex = levels.indexOf(level);
return checkLevelIndex >= currentLevelIndex;
}
}
LoggerFactory.instance = null;
LoggerFactory.defaultLogLevel = 'info';
/**
* HTTP client implementation using fetch API
* Provides browser-compatible HTTP functionality for making requests to the Omniconvert API
*
* @example
* ```typescript
* const client = new HttpClient('your-api-key');
* const response = await client.requestExperiments();
* ```
*
* @since 1.0.0
* @author Omniconvert
* @category HTTP
*/
class HttpClient {
/**
* Create a new HTTP client instance
*
* @param apiKey - API key for authentication with Omniconvert
* @param baseUri - Base URI for API requests (defaults to 'https://app.omniconvert.com/')
* @param cacheBypass - Whether to enable cache bypass for GET requests
*
* @example
* ```typescript
* // Basic usage with API key only
* const client = new HttpClient('your-api-key');
*
* // Custom base URI
* const client = new HttpClient('your-api-key', 'https://staging.omniconvert.com/');
*
* // With cache bypass enabled
* const client = new HttpClient('your-api-key', undefined, true);
* ```
*
* @since 1.0.0
*/
constructor(apiKey, baseUri, cacheBypass = false, logger) {
/**
* Whether cache bypass is enabled for GET requests
* @private
*/
this.cacheBypass = false;
this.cacheBypass = cacheBypass;
this.baseUri = baseUri || HttpClient.DEFAULT_API_BASE_URL;
this.logger = logger || LoggerFactory.getLogger();
// Ensure baseUri ends with /
if (!this.baseUri.endsWith('/')) {
this.baseUri += '/';
}
this.defaultHeaders = {
'Authorization': apiKey,
};
}
/**
* Make a generic HTTP request to the specified endpoint
*
* @param method - The HTTP method to use (GET, POST, PUT, DELETE, etc.)
* @param uri - The URI path to append to the base URL (optional)
* @param options - Additional fetch options including headers, body, etc.
* @returns Promise that resolves to the Response object
*
* @example
* ```typescript
* const response = await client.request('POST', 'api/users', {
* body: JSON.stringify({ name: 'John' }),
* headers: { 'Content-Type': 'application/json' }
* });
* ```
*
* @throws {Error} When the request fails or network error occurs
*
* @since 1.0.0
*/
async request(method, uri = '', options = {}) {
const url = this.buildUrl(uri);
// Merge default headers with provided headers
const headers = {
...this.defaultHeaders,
...(options.headers || {}),
};
// Add cache bypass header for GET requests if enabled
if (method.toUpperCase() === 'GET' && this.cacheBypass) {
const urlParts = new URL(url);
headers['X-Cache-Bypass'] = await this.generateCacheBypassHash(urlParts.host + urlParts.pathname);
}
const requestOptions = {
...options,
method: method.toUpperCase(),
headers,
// Add credentials for CORS requests
credentials: 'omit' // Don't send cookies to avoid CORS issues
};
try {
const response = await fetch(url, requestOptions);
// Log request for debugging
this.logger.debug('HttpClient request:', {
method: requestOptions.method,
url,
status: response.status,
statusText: response.statusText,
});
return response;
}
catch (error) {
this.logger.error('HttpClient request failed', error);
throw error;
}
}
/**
* Make a tracking request to the marketing tracking endpoint
*
* @param method - The HTTP method to use for the tracking request
* @param queryParams - Query parameters to include in the tracking request
* @returns Promise that resolves to the Response object
*
* @example
* ```typescript
* const response = await client.requestTracking('POST', {
* event: 'page_view',
* page: '/homepage'
* });
* ```
*
* @since 1.0.0
*/
async requestTracking(method, queryParams = {}) {
const queryString = this.buildQueryString(queryParams);
const uri = `${HttpClient.ROUTE_TRACKING}${queryString ? `?${queryString}` : ''}`;
return this.request(method, uri);
}
/**
* Request experiments configuration from the API
*
* @returns Promise that resolves to the Response object containing experiments data
*
* @example
* ```typescript
* const response = await client.requestExperiments();
* const experiments = await response.json();
* ```
*
* @since 1.0.0
*/
async requestExperiments() {
const uri = `api/${HttpClient.ROUTE_EXPERIMENTS}`;
return this.request('GET', uri);
}
/**
* Check if cache bypass is enabled for this client
*
* @returns true if cache bypass is enabled, false otherwise
*
* @example
* ```typescript
* if (client.hasCacheBypass()) {
* console.log('Cache bypass is enabled');
* }
* ```
*
* @since 1.0.0
*/
hasCacheBypass() {
return this.cacheBypass;
}
/**
* Get the base URI
*/
getBaseUri() {
return this.baseUri;
}
/**
* Build full URL from base URI and relative path
*/
buildUrl(uri) {
if (uri.startsWith('http://') || uri.startsWith('https://')) {
return uri;
}
// Remove leading slash from uri if present
const cleanUri = uri.startsWith('/') ? uri.substring(1) : uri;
return `${this.baseUri}${cleanUri}`;
}
/**
* Build query string from parameters object
*/
buildQueryString(params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
if (Array.isArray(value)) {
// Handle array values (e.g., skus)
value.forEach(item => {
searchParams.append(`${key}[]`, String(item));
});
}
else {
searchParams.append(key, String(value));
}
}
});
return searchParams.toString();
}
/**
* Generate cache bypass hash
* Matches PHP implementation: hash('sha256', str_rot13(base64_encode($input)))
*/
async generateCacheBypassHash(input) {
try {
const encoded = btoa(input);
const rotated = this.rot13(encoded);
// Use crypto API if available (modern browsers and Node.js)
if (typeof crypto !== 'undefined' && crypto.subtle) {
const encoder = new TextEncoder();
const data = encoder.encode(rotated);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
else {
// Fallback for older browsers - use a SHA-256 implementation
return this.sha256();
}
}
catch {
// Fallback if btoa fails - still need SHA-256
return typeof crypto !== 'undefined' && crypto.subtle
? await this.generateCacheBypassHashFallback(input)
: this.sha256();
}
}
/**
* Fallback cache bypass hash generation
*/
async generateCacheBypassHashFallback(input) {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Simple ROT13 implementation
*/
rot13(str) {
return str.replace(/[a-zA-Z]/g, (char) => {
const start = char <= 'Z' ? 65 : 97;
return String.fromCharCode(((char.charCodeAt(0) - start + 13) % 26) + start);
});
}
/**
* SHA-256 fallback implementation for older browsers
* Note: For production use with legacy browsers, consider including crypto-js library
*/
sha256() {
// For now, we'll throw an error to indicate crypto.subtle is required
// In a real implementation, you'd want to include a proper SHA-256 library
throw new Error('SHA-256 not available. This browser does not support crypto.subtle and no fallback library is included. Consider using a modern browser or including crypto-js library.');
}
/**
* Check if the client is in a browser environment
*/
static isBrowser() {
return typeof window !== 'undefined' && typeof fetch !== 'undefined';
}
/**
* Get user agent string (browser only)
*/
static getUserAgent() {
if (typeof navigator !== 'undefined' && navigator.userAgent) {
return navigator.userAgent;
}
return '';
}
}
/**
* Default base URL for Omniconvert API
* @default 'https://app.omniconvert.com/'
* @readonly
*/
HttpClient.DEFAULT_API_BASE_URL = 'https://app.omniconvert.com/';
/**
* API route for experiments endpoint
* @readonly
*/
HttpClient.ROUTE_EXPERIMENTS = 'experiments';
/**
* API route for tracking endpoint
* @readonly
*/
HttpClient.ROUTE_TRACKING = 'mktzsave';
/**
* Experiment entity representing an A/B test configuration
*/
class Experiment {
constructor(data) {
const rawData = data;
this.id = rawData.id;
this.name = rawData.name;
this.type = rawData.type;
this.targetPlatform = rawData.targetPlatform || rawData.target_platform;
this.priority = rawData.priority;
this.visitorsLimit = rawData.visitorsLimit || rawData.visitors_limit || 0;
this.visitorsNow = rawData.visitorsNow || rawData.visitors_now || 0;
this.globalJavascript = rawData.globalJavascript || rawData.global_javascript || '';
this.globalCss = rawData.globalCss || rawData.global_css || '';
this.deviceType = rawData.deviceType || rawData.device_type;
this.traficAllocation = rawData.traficAllocation || rawData.trafic_allocation || 0;
this.exclusive = rawData.exclusive;
this.whereIncluded = rawData.whereIncluded || rawData.where_included || [];
this.whereExcluded = rawData.whereExcluded || rawData.where_excluded || [];
this.whenRestriction = rawData.whenRestriction || rawData.when_restriction || [];
this.whenTimezone = rawData.whenTimezone || rawData.when_timezone || '';
// Handle both camelCase and snake_case date formats
this.whenStart = this.parseDate(rawData.whenStart || rawData.when_start);
this.whenEnd = this.parseDate(rawData.whenEnd || rawData.when_end);
this.slug = rawData.slug;
this.segment = rawData.segment;
this.variations = rawData.variations;
this.clicks = rawData.clicks || [];
this.trackEngagement = rawData.trackEngagement || rawData.track_engagement || false;
this.trackBounce = rawData.trackBounce || rawData.track_bounce || false;
this.goalTimeout = rawData.goalTimeout || rawData.goal_timeout || 0;
}
/**
* Get a variation by its key
*/
getVariation(variationKey) {
return Object.values(this.variations).find(variation => variation.slug === variationKey);
}
/**
* Get a variation by its ID
*/
getVariationById(variationId) {
return this.variations[variationId];
}
/**
* Get all variation keys
*/
getVariationKeys() {
return Object.values(this.variations).map(variation => variation.slug);
}
/**
* Get all variation IDs
*/
getVariationIds() {
return Object.keys(this.variations);
}
/**
* Check if the experiment is currently active based on timing
*/
isActive(currentTime = new Date()) {
// If no start date, assume it has started
const hasStarted = !this.whenStart || currentTime >= this.whenStart;
// If no end date, assume it hasn't ended
const hasNotEnded = !this.whenEnd || currentTime <= this.whenEnd;
return hasStarted && hasNotEnded;
}
/**
* Check if the experiment has variations
*/
hasVariations() {
return Object.keys(this.variations).length > 0;
}
/**
* Calculate total traffic allocation across all variations
*/
getTotalVariationAllocation() {
return Object.values(this.variations).reduce((total, variation) => total + (variation.traffic_allocation || 0), 0);
}
/**
* Convert to plain object for serialization
*/
toObject() {
return {
id: this.id,
name: this.name,
type: this.type,
targetPlatform: this.targetPlatform,
priority: this.priority,
visitorsLimit: this.visitorsLimit,
visitorsNow: this.visitorsNow,
globalJavascript: this.globalJavascript,
globalCss: this.globalCss,
deviceType: this.deviceType,
traficAllocation: this.traficAllocation,
exclusive: this.exclusive,
whereIncluded: this.whereIncluded,
whereExcluded: this.whereExcluded,
whenRestriction: this.whenRestriction,
whenTimezone: this.whenTimezone,
whenStart: this.whenStart ? this.whenStart.toISOString() : '',
whenEnd: this.whenEnd ? this.whenEnd.toISOString() : '',
slug: this.slug,
segment: this.segment,
variations: this.variations,
clicks: this.clicks,
trackEngagement: this.trackEngagement,
trackBounce: this.trackBounce,
goalTimeout: this.goalTimeout,
};
}
/**
* Create experiment from plain object
*/
static fromObject(obj) {
return new Experiment(obj);
}
/**
* Convert to JSON
*/
toJSON() {
return this.toObject();
}
/**
* Parse date string from API format to Date object
* Handles format: "2025-04-23 00:00:01" -> valid Date
* Returns null if no date string is provided
*/
parseDate(dateString) {
if (!dateString) {
return null;
}
// Convert "2025-04-23 00:00:01" to "2025-04-23T00:00:01" (ISO format)
const isoString = dateString.replace(' ', 'T');
const date = new Date(isoString);
// Check if the date is valid
if (isNaN(date.getTime())) {
console.warn(`Invalid date format: "${dateString}", returning null`);
return null;
}
return date;
}
}
Experiment.DEVICE_TYPE_MOBILE = 'mobile';
Experiment.DEVICE_TYPE_DESKTOP = 'desktop';
Experiment.DEVICE_TYPE_TABLET = 'tablet';
Experiment.DEVICE_TYPE_ALL = 'all';
Experiment.PLATFORM_SERVER_SIDE = 'server_side';
Experiment.PLATFORM_MOBILE = 'mobile';
/**
* Decision entity representing the result of experiment assignment
* Contains the experiment, variation, and when the decision was made
*/
class Decision {
constructor(experiment, variation) {
this.experiment = experiment;
this.variation = variation;
this.madeAt = new Date();
}
/**
* Get the experiment
*/
getExperiment() {
return this.experiment;
}
/**
* Get the experiment key (slug)
*/
getExperimentKey() {
return this.experiment.slug;
}
/**
* Get the experiment ID
*/
getExperimentId() {
return this.experiment.id;
}
/**
* Get the variation
*/
getVariation() {
return this.variation;
}
/**
* Get the variation key (slug)
*/
getVariationKey() {
return this.variation.slug;
}
/**
* Get the variation ID
*/
getVariationId() {
return this.variation.id;
}
/**
* Get all variation variables
*/
getVariationVariables() {
return this.variation.variables || [];
}
/**
* Get a specific variation variable by slug
*/
getVariationVariable(slug) {
const variable = this.variation.variables?.find(v => v.slug === slug);
return variable?.value ?? null;
}
/**
* Get variation variable with type safety
*/
getVariationVariableAs(slug) {
const value = this.getVariationVariable(slug);
return value !== null ? value : null;
}
/**
* Check if a variation variable exists
*/
hasVariationVariable(slug) {
return this.variation.variables?.some(v => v.slug === slug) ?? false;
}
/**
* Get the timestamp when the decision was made
*/
getMadeAt() {
return this.madeAt;
}
/**
* Get variation JavaScript code
*/
getVariationJavascript() {
return this.variation.javascript;
}
/**
* Get variation CSS code
*/
getVariationCss() {
return this.variation.css;
}
/**
* Get variation traffic allocation
*/
getVariationTrafficAllocation() {
return this.variation.traffic_allocation || 0;
}
/**
* Convert to plain object for serialization
*/
toObject() {
return {
experiment: this.experiment.toObject(),
variation: this.variation,
madeAt: this.madeAt.toISOString(),
};
}
/**
* Create decision from plain object (for deserialization)
*/
static fromObject(obj) {
const experiment = Experiment.fromObject(obj.experiment);
const variation = obj.variation;
const decision = new Decision(experiment, variation);
if (obj.madeAt) {
decision.madeAt = new Date(obj.madeAt);
}
return decision;
}
/**
* Convert to JSON
*/
toJSON() {
return this.toObject();
}
/**
* Get a summary of the decision for logging/debugging
*/
getSummary() {
return `Experiment: ${this.getExperimentKey()} (${this.getExperimentId()}), Variation: ${this.getVariationKey()} (${this.getVariationId()})`;
}
}
/**
* Traffic allocation manager
* Handles consistent user bucketing for experiments
*/
class TrafficAllocationManager {
constructor(storage, user, logger) {
this.storage = storage;
this.user = user;
this.logger = logger || LoggerFactory.getLogger();
}
/**
* Allocate traffic for an experiment
* Returns a number between 0-100 representing the user's allocation
*/
allocate(experimentId) {
const experimentKey = String(experimentId);
const trafficBucket = this.user.getTrafficAllocationBucket();
// Check if user already has an allocation for this experiment
const existingAllocation = trafficBucket.get(experimentKey);
if (existingAllocation !== undefined) {
const allocation = parseInt(existingAllocation, 10);
if (!isNaN(allocation)) {
this.logger.debug(`TrafficAllocationManager: Using existing allocation ${allocation} for experiment ${experimentId}`);
return allocation;
}
}
// Generate new allocation based on user ID and experiment ID
const allocation = this.generateAllocation(this.user.getId(), experimentId);
// Store the allocation for consistency
trafficBucket.add(experimentKey, String(allocation));
// Save user with updated allocation
this.storage.saveUser(this.user);
this.logger.debug(`TrafficAllocationManager: Generated new allocation ${allocation} for experiment ${experimentId}`);
return allocation;
}
/**
* Generate a deterministic allocation for a user and experiment
* Uses a hash-based approach to ensure consistency
*/
generateAllocation(userId, experimentId) {
// Create a seed from user ID and experiment ID
const seed = `${userId}_${experimentId}`;
// Generate a hash
const hash = this.simpleHash(seed);
// Convert hash to a number between 0-100
return hash % 101; // 0-100 inclusive
}
/**
* Simple hash function for consistent allocation
* Based on djb2 hash algorithm
*/
simpleHash(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
/**
* Clear allocation for a specific experiment
*/
clearAllocation(experimentId) {
const experimentKey = String(experimentId);
const trafficBucket = this.user.getTrafficAllocationBucket();
trafficBucket.remove(experimentKey);
this.storage.saveUser(this.user);
this.logger.debug(`TrafficAllocationManager: Cleared allocation for experiment ${experimentId}`);
}
/**
* Clear all allocations for the user
*/
clearAllAllocations() {
const trafficBucket = this.user.getTrafficAllocationBucket();
trafficBucket.clear();
this.storage.saveUser(this.user);
this.logger.debug('TrafficAllocationManager: Cleared all allocations');
}
/**
* Get all allocations for the user
*/
getAllAllocations() {
const trafficBucket = this.user.getTrafficAllocationBucket();
const allocations = {};
trafficBucket.keys().forEach(experimentKey => {
const allocation = trafficBucket.get(experimentKey);
if (allocation !== undefined) {
const numericAllocation = parseInt(allocation, 10);
if (!isNaN(numericAllocation)) {
allocations[experimentKey] = numericAllocation;
}
}
});
return allocations;
}
/**
* Check if user has allocation for experiment
*/
hasAllocation(experimentId) {
const experimentKey = String(experimentId);
return this.user.getTrafficAllocationBucket().has(experimentKey);
}
/**
* Get allocation for specific experiment
*/
getAllocation(experimentId) {
const experimentKey = String(experimentId);
const allocation = this.user.getTrafficAllocationBucket().get(experimentKey);
if (allocation !== undefined) {
const numericAllocation = parseInt(allocation, 10);
return !isNaN(numericAllocation) ? numericAllocation : null;
}
return null;
}
}
/**
* Voter registry
* Manages collection of voters for experiment evaluation
*/
class VoterRegistry {
constructor(voters = []) {
this.voters = [];
this.voters = [...voters];
}
/**
* Add a voter to the registry
*/
addVoter(voter) {
this.voters.push(voter);
}
/**
* Remove a voter by name
*/
removeVoter(voterName) {
const index = this.voters.findIndex(voter => voter.getName() === voterName);
if (index !== -1) {
this.voters.splice(index, 1);
return true;
}
return false;
}
/**
* Get all voters
*/
getVoters() {
return [...this.voters];
}
/**
* Get a voter by name
*/
getVoter(voterName) {
return this.voters.find(voter => voter.getName() === voterName);
}
/**
* Check if a voter exists
*/
hasVoter(voterName) {
return this.voters.some(voter => voter.getName() === voterName);
}
/**
* Get voter names
*/
getVoterNames() {
return this.voters.map(voter => voter.getName());
}
/**
* Clear all voters
*/
clear() {
this.voters = [];
}
/**
* Get the number of voters
*/
count() {
return this.voters.length;
}
}
/**
* Abstract base voter class
* Provides common functionality for all voters
*/
class AbstractVoter {
/**
* Get the voter name
*/
getName() {
return this.constructor.NAME;
}
/**
* Initialize the voter with optional logger
*/
constructor(logger) {
this.logger = logger || LoggerFactory.getLogger();
}
/**
* Log voting decision for debugging
*/
logVote(experiment, result, reason) {
this.logger.debug(`Voter ${this.getName()}: ${result ? 'PASS' : 'FAIL'} for experiment ${experiment.id}`, {
experiment_id: experiment.id,
experiment_slug: experiment.slug,
voter: this.getName(),
result,
reason,
});
}
}
AbstractVoter.NAME = 'abstract';
/**
* Abstract base class for context parameters
* Provides common functionality for all context parameter types
*/
class AbstractContextParam {
constructor(value, attributes = []) {
this.type = AbstractContextParam.TYPE_SCALAR;
this.attributes = [];
// Use the static NAME from the subclass
this.name = this.constructor.NAME;
this.type = this.constructor.TYPE;
this.value = value;
this.attributes = attributes;
}
getName() {
return this.name;
}
setName(name) {
this.name = name;
}
getType() {
return this.type;
}
setType(type) {
if (type !== AbstractContextParam.TYPE_SCALAR && type !== AbstractContextParam.TYPE_ARRAY) {
throw new Error('Invalid type');
}
this.type = type;
}
getValue() {
return this.value;
}
setValue(value) {
this.value = value;
}
getAttributes() {
return this.attributes;
}
addAttribute(attribute) {
this.attributes.push(attribute);
}
toArray() {
return {
name: this.name,
type: this.type,
value: this.value,
attributes: this.attributes,
};
}
/**
* Convert to string for easy debugging
*/
toString() {
return `${this.name}: ${String(this.value)}`;
}
/**
* Convert to JSON
*/
toJSON() {
return this.toArray();
}
}
AbstractContextParam.TYPE_SCALAR = 'scalar';
AbstractContextParam.TYPE_ARRAY = 'array';
AbstractContextParam.NAME = 'undefined';
AbstractContextParam.TYPE = AbstractContextParam.TYPE_SCALAR;
/**
* Device type context parameter
* Represents the user's device type (desktop, mobile, tablet)
*/
class DeviceTypeContextParam extends AbstractContextParam {
constructor(deviceType) {
super(deviceType);
}
getValue() {
return super.getValue() || 'desktop';
}
setValue(value) {
super.setValue(value);
}
}
DeviceTypeContextParam.NAME = 'device_type';
/**
* Device type voter
* Checks if the user's device type matches the experiment's target device type
*/
class DeviceTypeVoter extends AbstractVoter {
vote(context, experiment) {
// If experiment doesn't specify device type or is 'all', allow all devices
if (!experiment.deviceType || experiment.deviceType === 'all') {
this.logVote(experiment, true, 'No device type restriction or all devices allowed');
return true;
}
// Get device type from context
const deviceTypeParam = context.getParamByClass(DeviceTypeContextParam);
const userDeviceType = deviceTypeParam?.getValue() || 'desktop';
const matches = userDeviceType === experiment.deviceType;
this.logVote(experiment, matches, `User device: ${userDeviceType}, Required: ${experiment.deviceType}`);
return matches;
}
}
DeviceTypeVoter.NAME = 'device_type';
/**
* Timing voter
* Checks if the current time is within the experiment's time window
*/
class TimingVoter extends AbstractVoter {
vote(_context, experiment) {
const now = new Date();
// Check if experiment has started
if (experiment.whenStart && now < experiment.whenStart) {
this.logVote(experiment, false, `Experiment not yet started. Current: ${now.toISOString()}, Start: ${experiment.whenStart.toISOString()}`);
return false;
}
// Check if experiment has ended
if (experiment.whenEnd && now > experiment.whenEnd) {
this.logVote(experiment, false, `Experiment has ended. Current: ${now.toISOString()}, End: ${experiment.whenEnd.toISOString()}`);
return false;
}
this.logVote(experiment, true, `Experiment is active. Current: ${now.toISOString()}, Window: ${experiment.whenStart?.toISOString()} - ${experiment.whenEnd?.toISOString()}`);
return true;
}
}
TimingVoter.NAME = 'timing';
/**
* URL location context parameter
* Represents the current page URL
*/
class UrlLocationContextParam extends AbstractContextParam {
constructor(url) {
super(url);
}
getValue() {
return super.getValue() || '';
}
setValue(value) {
super.setValue(value);
}
}
UrlLocationContextParam.NAME = 'url_location';
/**
* Audience voter
* Checks if the user matches the experiment's audience criteria
* Based on whereIncluded and whereExcluded rules
*/
class AudienceVoter extends AbstractVoter {
vote(context, experiment) {
// Get URL from context for debugging
const urlParam = context.getParamByClass(UrlLocationContextParam);
const currentUrl = urlParam ? urlParam.getValue() : 'No URL found in context';
// Log detailed debugging information
this.logger.debug('AudienceVoter: Starting evaluation', {
experimentId: experiment.id,
experimentSlug: experiment.slug,
currentUrl: currentUrl,
hasWhereIncluded: this.hasRules(experiment.whereIncluded),
hasWhereExcluded: this.hasRules(experiment.whereExcluded),
whereIncludedRules: experiment.whereIncluded || [],
whereExcludedRules: experiment.whereExcluded || [],
whereIncludedType: typeof experiment.whereIncluded,
whereExcludedType: typeof experiment.whereExcluded
});
// Check exclusion rules first
if (this.hasRules(experiment.whereExcluded)) {
this.logger.debug('AudienceVoter: Evaluating exclusion rules', {
experimentId: experiment.id,
currentUrl: currentUrl,
exclusionRules: experiment.whereExcluded
});
const isExcluded = this.evaluateRules(context, experiment.whereExcluded);
if (isExcluded) {
this.logVote(experiment, false, `User matches exclusion rules - URL: ${currentUrl}`);
return false;
}
}
// Check inclusion rules
if (this.hasRules(experiment.whereIncluded)) {
this.logger.debug('AudienceVoter: Evaluating inclusion rules', {
experimentId: experiment.id,
currentUrl: currentUrl,
inclusionRules: experiment.whereIncluded
});
const isIncluded = this.evaluateRules(context, experiment.whereIncluded);
if (!isIncluded) {
this.logVote(experiment, false, `User does not match inclusion rules - URL: ${currentUrl}`);
return false;
}
}
this.logVote(experiment, true, `User passes audience criteria - URL: ${currentUrl}`);
return true;
}
/**
* Check if rules exist and are not empty
* Handles both arrays and objects
*/
hasRules(rules) {
if (!rules) {
this.logger.debug('AudienceVoter: hasRules - no rules (null/undefined)', { rules });
return false;
}
if (Array.isArray(rules)) {
const hasRules = rules.length > 0;
this.logger.debug('AudienceVoter: hasRules - array check', {
rules,
length: rules.length,
hasRules
});
return hasRules;
}
if (typeof rules === 'object' && rules !== null) {
const keys = Object.keys(rules);
const hasRules = keys.length > 0;
this.logger.debug('AudienceVoter: hasRules - object check', {
rules,
keys,
keyCount: keys.length,
hasRules
});
return hasRules;
}
this.logger.debug('AudienceVoter: hasRules - unsupported type', {
rules,
type: typeof rules
});
return false;
}
/**
* Evaluate audience rules against user context
*/
evaluateRules(context, rules) {
// Get URL from context for detailed logging
const urlParam = context.getParamByClass(UrlLocationContextParam);
const currentUrl = urlParam ? urlParam.getValue() : 'No URL found in context';
this.logger.debug('AudienceVoter: Starting rule evaluation', {
currentUrl: currentUrl,
rules: rules,
ruleType: typeof rules,
isArray: Array.isArray(rules),
isNull: rules === null,
isUndefined: rules === undefined
});
// Handle null/undefined rules
if (rules === null || rules === undefined) {
this.logger.debug('AudienceVoter: No rules (null/undefined), allowing by default');
return true;
}
// Handle array of rules
if (Array.isArray(rules)) {
if (rules.length === 0) {
this.logger.debug('AudienceVoter: Empty rules array, allowing by default');
return true;
}
this.logger.debug('AudienceVoter: Processing rules array', {
currentUrl: currentUrl,
totalRules: rules.length
});
try {
// Process each rule in the array
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
this.logger.debug(`AudienceVoter: Evaluating rule ${i + 1}/${rules.length}`, {
currentUrl: currentUrl,
ruleIndex: i,
rule: rule,
ruleType: typeof rule
});
const ruleResult = this.evaluateRule(context, rule);
this.logger.debug(`AudienceVoter: Rule ${i + 1} result`, {
currentUrl: currentUrl,
ruleIndex: i,
result: ruleResult
});
if (ruleResult) {
this.logger.debug('AudienceVoter: Rule matched, returning true', {
currentUrl: currentUrl,
matchingRuleIndex: i,
matchingRule: rule
});
return true;
}
}
this.logger.debug('AudienceVoter: No rules in array matched, returning false', {
currentUrl: currentUrl,
totalRulesEvaluated: rules.length
});
return false;
}
catch (error) {
this.logger.warning('AudienceVoter: Error evaluating rules array', {
currentUrl: currentUrl,
error: error,
rules: rules
});
return false; // Changed: Default to DENYING if rules can't be evaluated
}