UNPKG

@omniconvert/server-side-testing-sdk

Version:

TypeScript SDK for server-side A/B testing and experimentation

1,385 lines (1,370 loc) 277 kB
(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 }