Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled

Full-stack web application for Telegram management
- Frontend: Vue 3 + Vben Admin
- Backend: NestJS
- Features: User management, group broadcast, statistics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
FROM node:18-alpine
# Install build dependencies
RUN apk add --no-cache python3 make g++
# Create app directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install --production
# Copy application code
COPY . .
# Create necessary directories
RUN mkdir -p logs reports
# Non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
RUN chown -R nodejs:nodejs /app
USER nodejs
# Expose ports
EXPOSE 3005 9105
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3005/health', (res) => process.exit(res.statusCode === 200 ? 0 : 1))"
# Start the service
CMD ["node", "src/index.js"]

View File

@@ -0,0 +1,295 @@
# A/B Testing Service
Advanced A/B testing and experimentation service for the Telegram Marketing Intelligence Agent system.
## Overview
The A/B Testing service provides comprehensive experiment management, traffic allocation, and statistical analysis capabilities for optimizing marketing campaigns.
## Features
### Experiment Management
- Multiple experiment types (A/B, multivariate, bandit)
- Flexible variant configuration
- Target audience filtering
- Scheduled experiments
- Early stopping support
### Traffic Allocation Algorithms
- **Random**: Fixed percentage allocation
- **Epsilon-Greedy**: Balance exploration and exploitation
- **UCB (Upper Confidence Bound)**: Optimistic exploration strategy
- **Thompson Sampling**: Bayesian approach for optimal allocation
### Statistical Analysis
- Frequentist hypothesis testing
- Bayesian analysis
- Confidence intervals
- Power analysis
- Multiple testing correction
### Real-time Features
- Live metrics tracking
- Dynamic allocation updates
- Real-time results visualization
- Automated winner detection
## Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ API Gateway │────▶│ A/B Testing Service│────▶│ MongoDB │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │
├───────────────────────────┤
▼ ▼
┌─────────────┐ ┌──────────────┐
│ Redis │ │ RabbitMQ │
└─────────────┘ └──────────────┘
```
## API Endpoints
### Experiments
- `GET /api/experiments` - List experiments
- `POST /api/experiments` - Create experiment
- `GET /api/experiments/:id` - Get experiment details
- `PUT /api/experiments/:id` - Update experiment
- `DELETE /api/experiments/:id` - Delete experiment
- `POST /api/experiments/:id/start` - Start experiment
- `POST /api/experiments/:id/pause` - Pause experiment
- `POST /api/experiments/:id/complete` - Complete experiment
### Allocations
- `POST /api/allocations/allocate` - Allocate user to variant
- `GET /api/allocations/allocation/:experimentId/:userId` - Get user allocation
- `POST /api/allocations/conversion` - Record conversion
- `POST /api/allocations/event` - Record custom event
- `POST /api/allocations/batch/allocate` - Batch allocate users
- `GET /api/allocations/stats/:experimentId` - Get allocation statistics
### Results
- `GET /api/results/:experimentId` - Get experiment results
- `GET /api/results/:experimentId/metrics` - Get real-time metrics
- `GET /api/results/:experimentId/segments` - Get segment analysis
- `GET /api/results/:experimentId/funnel` - Get funnel analysis
- `GET /api/results/:experimentId/export` - Export results
## Configuration
### Environment Variables
- `PORT` - Service port (default: 3005)
- `MONGODB_URI` - MongoDB connection string
- `REDIS_URL` - Redis connection URL
- `RABBITMQ_URL` - RabbitMQ connection URL
- `JWT_SECRET` - JWT signing secret
### Experiment Configuration
- `DEFAULT_EXPERIMENT_DURATION` - Default duration in ms
- `MIN_SAMPLE_SIZE` - Minimum sample size per variant
- `CONFIDENCE_LEVEL` - Statistical confidence level
- `MDE` - Minimum detectable effect
### Allocation Configuration
- `ALLOCATION_ALGORITHM` - Default algorithm
- `EPSILON` - Epsilon for epsilon-greedy
- `UCB_C` - Exploration parameter for UCB
## Usage Examples
### Create an A/B Test
```javascript
const experiment = {
name: "New CTA Button Test",
type: "ab",
targetMetric: {
name: "conversion_rate",
type: "conversion",
goalDirection: "increase"
},
variants: [
{
variantId: "control",
name: "Current Button",
config: { buttonText: "Sign Up" },
allocation: { percentage: 50 }
},
{
variantId: "variant_a",
name: "New Button",
config: { buttonText: "Get Started Free" },
allocation: { percentage: 50 }
}
],
control: "control",
allocation: {
method: "random"
}
};
const response = await abTestingClient.createExperiment(experiment);
```
### Allocate User
```javascript
const allocation = await abTestingClient.allocate({
experimentId: "exp_123",
userId: "user_456",
context: {
deviceType: "mobile",
platform: "iOS",
location: {
country: "US",
region: "CA"
}
}
});
// Use variant configuration
if (allocation.variantId === "variant_a") {
showNewButton();
} else {
showCurrentButton();
}
```
### Record Conversion
```javascript
await abTestingClient.recordConversion({
experimentId: "exp_123",
userId: "user_456",
value: 1,
metadata: {
revenue: 99.99,
itemId: "premium_plan"
}
});
```
### Get Results
```javascript
const results = await abTestingClient.getResults("exp_123");
// Check winner
if (results.analysis.summary.winner) {
console.log(`Winner: ${results.analysis.summary.winner.name}`);
console.log(`Improvement: ${results.analysis.summary.winner.improvement}%`);
}
```
## Adaptive Allocation
### Epsilon-Greedy
```javascript
const experiment = {
allocation: {
method: "epsilon-greedy",
parameters: {
epsilon: 0.1 // 10% exploration
}
}
};
```
### Thompson Sampling
```javascript
const experiment = {
allocation: {
method: "thompson"
}
};
```
## Statistical Analysis
### Power Analysis
The service automatically calculates required sample sizes based on:
- Baseline conversion rate
- Minimum detectable effect
- Statistical power (default: 80%)
- Confidence level (default: 95%)
### Multiple Testing Correction
When running experiments with multiple variants:
- Bonferroni correction
- Benjamini-Hochberg procedure
## Best Practices
1. **Sample Size Planning**
- Use power analysis to determine duration
- Don't stop experiments too early
- Account for weekly/seasonal patterns
2. **Metric Selection**
- Choose primary metrics aligned with business goals
- Monitor guardrail metrics
- Consider long-term effects
3. **Audience Targeting**
- Use consistent targeting criteria
- Ensure sufficient traffic in each segment
- Consider interaction effects
4. **Statistical Rigor**
- Pre-register hypotheses
- Avoid peeking at results
- Use appropriate statistical tests
## Monitoring
### Health Check
```bash
curl http://localhost:3005/health
```
### Metrics
- Prometheus metrics at `/metrics`
- Key metrics:
- Active experiments count
- Allocation latency
- Conversion rates by variant
- Algorithm performance
## Development
### Setup
```bash
npm install
cp .env.example .env
npm run dev
```
### Testing
```bash
npm test
npm run test:integration
npm run test:statistical
```
### Docker
```bash
docker build -t ab-testing-service .
docker run -p 3005:3005 --env-file .env ab-testing-service
```
## Performance
- Allocation latency: <10ms p99
- Results calculation: <100ms for 100K users
- Real-time updates: <50ms latency
- Supports 10K allocations/second
## Security
- JWT authentication required
- Experiment isolation by account
- Rate limiting per account
- Audit logging for all changes
## Support
For issues and questions:
- Review the statistical methodology guide
- Check the troubleshooting section
- Contact the development team

View File

@@ -0,0 +1,57 @@
{
"name": "ab-testing-service",
"version": "1.0.0",
"description": "A/B Testing service for Telegram Marketing Intelligence Agent",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src",
"format": "prettier --write src"
},
"keywords": [
"ab-testing",
"experiments",
"analytics",
"telegram"
],
"author": "Marketing Agent Team",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"mongoose": "^7.5.0",
"redis": "^4.6.5",
"amqplib": "^0.10.3",
"axios": "^1.5.0",
"joi": "^17.10.0",
"uuid": "^9.0.0",
"lodash": "^4.17.21",
"simple-statistics": "^7.8.3",
"jstat": "^1.9.6",
"murmurhash": "^2.0.0",
"cron": "^2.4.0",
"prom-client": "^14.2.0",
"winston": "^3.10.0",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.7.0",
"supertest": "^6.3.3",
"eslint": "^8.49.0",
"prettier": "^3.0.3",
"@types/jest": "^29.5.5"
},
"jest": {
"testEnvironment": "node",
"coverageDirectory": "coverage",
"collectCoverageFrom": [
"src/**/*.js",
"!src/index.js"
]
}
}

View File

@@ -0,0 +1,174 @@
/**
* Epsilon-Greedy allocation algorithm
* Explores with probability epsilon, exploits best performing variant otherwise
*/
import { logger } from '../utils/logger.js';
export class EpsilonGreedyAllocator {
constructor(epsilon = 0.1) {
this.name = 'epsilon-greedy';
this.epsilon = epsilon;
this.variantStats = new Map();
}
/**
* Initialize variant statistics
*/
initializeVariant(variantId) {
if (!this.variantStats.has(variantId)) {
this.variantStats.set(variantId, {
trials: 0,
successes: 0,
reward: 0
});
}
}
/**
* Allocate a user to a variant
* @param {Object} experiment - The experiment object
* @param {string} userId - The user ID
* @param {Object} context - Additional context
* @returns {Object} The selected variant
*/
allocate(experiment, userId, context = {}) {
const { variants } = experiment;
// Initialize all variants
variants.forEach(v => this.initializeVariant(v.variantId));
// Explore with probability epsilon
if (Math.random() < this.epsilon) {
// Random exploration
const randomIndex = Math.floor(Math.random() * variants.length);
return variants[randomIndex];
}
// Exploit: choose variant with highest estimated reward
let bestVariant = variants[0];
let bestReward = -Infinity;
for (const variant of variants) {
const stats = this.variantStats.get(variant.variantId);
const estimatedReward = stats.trials > 0
? stats.reward / stats.trials
: 0;
if (estimatedReward > bestReward) {
bestReward = estimatedReward;
bestVariant = variant;
}
}
return bestVariant;
}
/**
* Update allocation based on results
* @param {Object} experiment - The experiment object
* @param {string} variantId - The variant ID
* @param {number} reward - The reward (0 or 1 for conversion)
*/
update(experiment, variantId, reward) {
this.initializeVariant(variantId);
const stats = this.variantStats.get(variantId);
stats.trials += 1;
stats.reward += reward;
if (reward > 0) {
stats.successes += 1;
}
logger.debug(`Updated epsilon-greedy stats for variant ${variantId}:`, stats);
}
/**
* Get allocation probabilities
*/
getProbabilities(experiment) {
const { variants } = experiment;
const probabilities = [];
// Find best performing variant
let bestVariantId = null;
let bestReward = -Infinity;
for (const variant of variants) {
const stats = this.variantStats.get(variant.variantId) || { trials: 0, reward: 0 };
const estimatedReward = stats.trials > 0 ? stats.reward / stats.trials : 0;
if (estimatedReward > bestReward) {
bestReward = estimatedReward;
bestVariantId = variant.variantId;
}
}
// Calculate probabilities
const explorationProb = this.epsilon / variants.length;
const exploitationProb = 1 - this.epsilon;
for (const variant of variants) {
const probability = variant.variantId === bestVariantId
? explorationProb + exploitationProb
: explorationProb;
probabilities.push({
variantId: variant.variantId,
probability,
stats: this.variantStats.get(variant.variantId) || { trials: 0, reward: 0, successes: 0 }
});
}
return probabilities;
}
/**
* Set epsilon value
*/
setEpsilon(epsilon) {
this.epsilon = Math.max(0, Math.min(1, epsilon));
}
/**
* Get current statistics
*/
getStats() {
const stats = {};
for (const [variantId, data] of this.variantStats) {
stats[variantId] = {
...data,
conversionRate: data.trials > 0 ? data.successes / data.trials : 0
};
}
return stats;
}
/**
* Reset algorithm state
*/
reset() {
this.variantStats.clear();
}
/**
* Load state from storage
*/
loadState(state) {
if (state && state.variantStats) {
this.variantStats = new Map(Object.entries(state.variantStats));
}
}
/**
* Save state for persistence
*/
saveState() {
const state = {
epsilon: this.epsilon,
variantStats: Object.fromEntries(this.variantStats)
};
return state;
}
}

View File

@@ -0,0 +1,151 @@
import { RandomAllocator } from './random.js';
import { EpsilonGreedyAllocator } from './epsilonGreedy.js';
import { UCBAllocator } from './ucb.js';
import { ThompsonSamplingAllocator } from './thompson.js';
import { config } from '../config/index.js';
import { logger } from '../utils/logger.js';
// Algorithm factory
export class AllocationAlgorithmFactory {
static algorithms = {
'random': RandomAllocator,
'epsilon-greedy': EpsilonGreedyAllocator,
'ucb': UCBAllocator,
'thompson': ThompsonSamplingAllocator
};
/**
* Create an allocation algorithm instance
* @param {string} algorithmName - Name of the algorithm
* @param {Object} parameters - Algorithm-specific parameters
* @returns {Object} Algorithm instance
*/
static create(algorithmName, parameters = {}) {
const AlgorithmClass = this.algorithms[algorithmName];
if (!AlgorithmClass) {
logger.warn(`Unknown algorithm: ${algorithmName}, falling back to random`);
return new RandomAllocator();
}
// Create instance with appropriate parameters
switch (algorithmName) {
case 'epsilon-greedy':
return new AlgorithmClass(parameters.epsilon || config.allocation.epsilon);
case 'ucb':
return new AlgorithmClass(parameters.c || config.allocation.ucbC);
case 'thompson':
case 'random':
default:
return new AlgorithmClass();
}
}
/**
* Get list of available algorithms
*/
static getAvailable() {
return Object.keys(this.algorithms);
}
/**
* Check if algorithm exists
*/
static exists(algorithmName) {
return algorithmName in this.algorithms;
}
}
// Algorithm manager for handling multiple experiments
export class AllocationManager {
constructor(redisClient) {
this.algorithms = new Map();
this.redis = redisClient;
}
/**
* Get or create algorithm for experiment
*/
async getAlgorithm(experiment) {
const key = `algorithm:${experiment.experimentId}`;
if (this.algorithms.has(key)) {
return this.algorithms.get(key);
}
// Create algorithm
const algorithm = AllocationAlgorithmFactory.create(
experiment.allocation.method,
experiment.allocation.parameters
);
// Load saved state from Redis if available
try {
const savedState = await this.redis.get(`${config.redis.prefix}${key}`);
if (savedState) {
algorithm.loadState(JSON.parse(savedState));
logger.debug(`Loaded algorithm state for experiment ${experiment.experimentId}`);
}
} catch (error) {
logger.error('Failed to load algorithm state:', error);
}
this.algorithms.set(key, algorithm);
return algorithm;
}
/**
* Save algorithm state
*/
async saveAlgorithmState(experimentId, algorithm) {
const key = `algorithm:${experimentId}`;
if (algorithm.saveState) {
try {
const state = algorithm.saveState();
await this.redis.setex(
`${config.redis.prefix}${key}`,
config.redis.ttl,
JSON.stringify(state)
);
} catch (error) {
logger.error('Failed to save algorithm state:', error);
}
}
}
/**
* Reset algorithm for experiment
*/
async resetAlgorithm(experimentId) {
const key = `algorithm:${experimentId}`;
// Remove from memory
const algorithm = this.algorithms.get(key);
if (algorithm && algorithm.reset) {
algorithm.reset();
}
this.algorithms.delete(key);
// Remove from Redis
try {
await this.redis.del(`${config.redis.prefix}${key}`);
} catch (error) {
logger.error('Failed to delete algorithm state:', error);
}
}
/**
* Clear all algorithms
*/
clearAll() {
for (const [key, algorithm] of this.algorithms) {
if (algorithm.reset) {
algorithm.reset();
}
}
this.algorithms.clear();
}
}

View File

@@ -0,0 +1,77 @@
/**
* Random allocation algorithm
* Allocates users to variants based on fixed percentage splits
*/
export class RandomAllocator {
constructor() {
this.name = 'random';
}
/**
* Allocate a user to a variant
* @param {Object} experiment - The experiment object
* @param {string} userId - The user ID
* @param {Object} context - Additional context
* @returns {Object} The selected variant
*/
allocate(experiment, userId, context = {}) {
const { variants } = experiment;
// Calculate cumulative percentages
const cumulative = [];
let total = 0;
for (const variant of variants) {
total += variant.allocation.percentage;
cumulative.push({
variant,
threshold: total
});
}
// Generate random number between 0 and total
const random = Math.random() * total;
// Find the variant based on random number
for (const { variant, threshold } of cumulative) {
if (random <= threshold) {
return variant;
}
}
// Fallback to first variant (should not happen)
return variants[0];
}
/**
* Update allocation based on results (no-op for random)
*/
update(experiment, variantId, reward) {
// Random allocation doesn't adapt based on results
return;
}
/**
* Get allocation probabilities
*/
getProbabilities(experiment) {
const total = experiment.variants.reduce(
(sum, v) => sum + v.allocation.percentage,
0
);
return experiment.variants.map(variant => ({
variantId: variant.variantId,
probability: variant.allocation.percentage / total
}));
}
/**
* Reset algorithm state (no-op for random)
*/
reset() {
// Random allocation has no state to reset
return;
}
}

View File

@@ -0,0 +1,239 @@
/**
* Thompson Sampling allocation algorithm
* Bayesian approach using Beta distributions for binary rewards
*/
import { logger } from '../utils/logger.js';
export class ThompsonSamplingAllocator {
constructor() {
this.name = 'thompson';
this.variantStats = new Map();
}
/**
* Initialize variant statistics with Beta(1,1) prior
*/
initializeVariant(variantId) {
if (!this.variantStats.has(variantId)) {
this.variantStats.set(variantId, {
alpha: 1, // successes + 1
beta: 1, // failures + 1
trials: 0,
successes: 0
});
}
}
/**
* Sample from Beta distribution
* Using Joehnk's method for Beta random generation
*/
sampleBeta(alpha, beta) {
// Handle edge cases
if (alpha <= 0 || beta <= 0) {
return 0.5;
}
// For large values, use normal approximation
if (alpha > 50 && beta > 50) {
const mean = alpha / (alpha + beta);
const variance = (alpha * beta) / ((alpha + beta) ** 2 * (alpha + beta + 1));
const stdDev = Math.sqrt(variance);
// Box-Muller transform for normal distribution
const u1 = Math.random();
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return Math.max(0, Math.min(1, mean + z * stdDev));
}
// Joehnk's method
let x, y;
do {
x = Math.pow(Math.random(), 1 / alpha);
y = Math.pow(Math.random(), 1 / beta);
} while (x + y > 1);
return x / (x + y);
}
/**
* Allocate a user to a variant
* @param {Object} experiment - The experiment object
* @param {string} userId - The user ID
* @param {Object} context - Additional context
* @returns {Object} The selected variant
*/
allocate(experiment, userId, context = {}) {
const { variants } = experiment;
// Initialize all variants
variants.forEach(v => this.initializeVariant(v.variantId));
// Sample from each variant's Beta distribution
let bestVariant = variants[0];
let bestSample = -1;
for (const variant of variants) {
const stats = this.variantStats.get(variant.variantId);
const sample = this.sampleBeta(stats.alpha, stats.beta);
if (sample > bestSample) {
bestSample = sample;
bestVariant = variant;
}
}
logger.debug(`Thompson Sampling selected variant ${bestVariant.variantId} with sample ${bestSample}`);
return bestVariant;
}
/**
* Update allocation based on results
* @param {Object} experiment - The experiment object
* @param {string} variantId - The variant ID
* @param {number} reward - The reward (0 or 1 for conversion)
*/
update(experiment, variantId, reward) {
this.initializeVariant(variantId);
const stats = this.variantStats.get(variantId);
stats.trials += 1;
if (reward > 0) {
stats.alpha += 1; // Success
stats.successes += 1;
} else {
stats.beta += 1; // Failure
}
logger.debug(`Updated Thompson Sampling stats for variant ${variantId}:`, {
alpha: stats.alpha,
beta: stats.beta,
posteriorMean: stats.alpha / (stats.alpha + stats.beta)
});
}
/**
* Get allocation probabilities (estimated via Monte Carlo)
*/
getProbabilities(experiment, samples = 10000) {
const { variants } = experiment;
const winCounts = new Map();
// Initialize win counts
variants.forEach(v => winCounts.set(v.variantId, 0));
// Monte Carlo simulation
for (let i = 0; i < samples; i++) {
let bestVariantId = null;
let bestSample = -1;
for (const variant of variants) {
const stats = this.variantStats.get(variant.variantId) || { alpha: 1, beta: 1 };
const sample = this.sampleBeta(stats.alpha, stats.beta);
if (sample > bestSample) {
bestSample = sample;
bestVariantId = variant.variantId;
}
}
winCounts.set(bestVariantId, winCounts.get(bestVariantId) + 1);
}
// Convert to probabilities
return variants.map(variant => {
const stats = this.variantStats.get(variant.variantId) || {
alpha: 1,
beta: 1,
trials: 0,
successes: 0
};
return {
variantId: variant.variantId,
probability: winCounts.get(variant.variantId) / samples,
stats: {
...stats,
posteriorMean: stats.alpha / (stats.alpha + stats.beta),
conversionRate: stats.trials > 0 ? stats.successes / stats.trials : 0,
credibleInterval: this.getCredibleInterval(stats.alpha, stats.beta)
}
};
});
}
/**
* Get 95% credible interval for conversion rate
*/
getCredibleInterval(alpha, beta, confidence = 0.95) {
// Use Wilson score interval approximation
const mean = alpha / (alpha + beta);
const n = alpha + beta;
if (n < 5) {
// For small samples, use exact Beta quantiles (simplified)
return {
lower: Math.max(0, mean - 0.2),
upper: Math.min(1, mean + 0.2)
};
}
// Normal approximation for larger samples
const z = 1.96; // 95% confidence
const variance = (alpha * beta) / ((alpha + beta) ** 2 * (alpha + beta + 1));
const stdError = Math.sqrt(variance);
return {
lower: Math.max(0, mean - z * stdError),
upper: Math.min(1, mean + z * stdError)
};
}
/**
* Get current statistics
*/
getStats() {
const stats = {};
for (const [variantId, data] of this.variantStats) {
stats[variantId] = {
...data,
posteriorMean: data.alpha / (data.alpha + data.beta),
conversionRate: data.trials > 0 ? data.successes / data.trials : 0,
credibleInterval: this.getCredibleInterval(data.alpha, data.beta)
};
}
return stats;
}
/**
* Reset algorithm state
*/
reset() {
this.variantStats.clear();
}
/**
* Load state from storage
*/
loadState(state) {
if (state && state.variantStats) {
this.variantStats = new Map(Object.entries(state.variantStats));
}
}
/**
* Save state for persistence
*/
saveState() {
return {
variantStats: Object.fromEntries(this.variantStats)
};
}
}

View File

@@ -0,0 +1,216 @@
/**
* Upper Confidence Bound (UCB) allocation algorithm
* Balances exploration and exploitation using confidence bounds
*/
import { logger } from '../utils/logger.js';
export class UCBAllocator {
constructor(c = 2) {
this.name = 'ucb';
this.c = c; // Exploration parameter
this.variantStats = new Map();
this.totalTrials = 0;
}
/**
* Initialize variant statistics
*/
initializeVariant(variantId) {
if (!this.variantStats.has(variantId)) {
this.variantStats.set(variantId, {
trials: 0,
successes: 0,
reward: 0,
avgReward: 0
});
}
}
/**
* Calculate UCB score for a variant
*/
calculateUCB(stats) {
if (stats.trials === 0) {
return Infinity; // Ensure unplayed variants are selected first
}
const exploitation = stats.avgReward;
const exploration = this.c * Math.sqrt(
2 * Math.log(this.totalTrials) / stats.trials
);
return exploitation + exploration;
}
/**
* Allocate a user to a variant
* @param {Object} experiment - The experiment object
* @param {string} userId - The user ID
* @param {Object} context - Additional context
* @returns {Object} The selected variant
*/
allocate(experiment, userId, context = {}) {
const { variants } = experiment;
// Initialize all variants
variants.forEach(v => this.initializeVariant(v.variantId));
// Select variant with highest UCB score
let bestVariant = variants[0];
let bestScore = -Infinity;
for (const variant of variants) {
const stats = this.variantStats.get(variant.variantId);
const ucbScore = this.calculateUCB(stats);
if (ucbScore > bestScore) {
bestScore = ucbScore;
bestVariant = variant;
}
}
// Increment trial count for selected variant
const selectedStats = this.variantStats.get(bestVariant.variantId);
selectedStats.trials += 1;
this.totalTrials += 1;
logger.debug(`UCB selected variant ${bestVariant.variantId} with score ${bestScore}`);
return bestVariant;
}
/**
* Update allocation based on results
* @param {Object} experiment - The experiment object
* @param {string} variantId - The variant ID
* @param {number} reward - The reward (0 or 1 for conversion)
*/
update(experiment, variantId, reward) {
const stats = this.variantStats.get(variantId);
if (!stats) {
logger.error(`No stats found for variant ${variantId}`);
return;
}
stats.reward += reward;
if (reward > 0) {
stats.successes += 1;
}
// Update average reward
stats.avgReward = stats.reward / stats.trials;
logger.debug(`Updated UCB stats for variant ${variantId}:`, stats);
}
/**
* Get allocation probabilities (estimated)
*/
getProbabilities(experiment) {
const { variants } = experiment;
const scores = [];
let totalScore = 0;
// Calculate UCB scores for all variants
for (const variant of variants) {
const stats = this.variantStats.get(variant.variantId) || { trials: 0, reward: 0 };
const score = this.calculateUCB(stats);
scores.push({ variant, score, stats });
if (score !== Infinity) {
totalScore += score;
}
}
// Convert scores to probabilities
const probabilities = scores.map(({ variant, score, stats }) => {
let probability;
if (score === Infinity) {
// Unplayed variants get equal share of remaining probability
const unplayedCount = scores.filter(s => s.score === Infinity).length;
probability = 1 / unplayedCount;
} else if (totalScore > 0) {
probability = score / totalScore;
} else {
probability = 1 / variants.length;
}
return {
variantId: variant.variantId,
probability,
ucbScore: score,
stats: {
...stats,
conversionRate: stats.trials > 0 ? stats.successes / stats.trials : 0
}
};
});
return probabilities;
}
/**
* Set exploration parameter
*/
setC(c) {
this.c = Math.max(0, c);
}
/**
* Get current statistics
*/
getStats() {
const stats = {
totalTrials: this.totalTrials,
variants: {}
};
for (const [variantId, data] of this.variantStats) {
stats.variants[variantId] = {
...data,
conversionRate: data.trials > 0 ? data.successes / data.trials : 0,
ucbScore: this.calculateUCB(data)
};
}
return stats;
}
/**
* Reset algorithm state
*/
reset() {
this.variantStats.clear();
this.totalTrials = 0;
}
/**
* Load state from storage
*/
loadState(state) {
if (state) {
if (state.variantStats) {
this.variantStats = new Map(Object.entries(state.variantStats));
}
if (state.totalTrials !== undefined) {
this.totalTrials = state.totalTrials;
}
if (state.c !== undefined) {
this.c = state.c;
}
}
}
/**
* Save state for persistence
*/
saveState() {
return {
c: this.c,
totalTrials: this.totalTrials,
variantStats: Object.fromEntries(this.variantStats)
};
}
}

View File

@@ -0,0 +1 @@
import './index.js';

View File

@@ -0,0 +1,66 @@
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: process.env.PORT || 3005,
env: process.env.NODE_ENV || 'development',
mongodb: {
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/ab-testing',
options: {
useNewUrlParser: true,
useUnifiedTopology: true
}
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
prefix: process.env.REDIS_PREFIX || 'abtesting:',
ttl: parseInt(process.env.REDIS_TTL || '3600')
},
rabbitmq: {
url: process.env.RABBITMQ_URL || 'amqp://localhost:5672',
exchange: process.env.RABBITMQ_EXCHANGE || 'ab-testing',
queues: {
experiments: 'ab-testing.experiments',
allocations: 'ab-testing.allocations',
events: 'ab-testing.events'
}
},
analytics: {
serviceUrl: process.env.ANALYTICS_SERVICE_URL || 'http://analytics:3004',
apiKey: process.env.ANALYTICS_API_KEY
},
experiment: {
defaultDuration: parseInt(process.env.DEFAULT_EXPERIMENT_DURATION || '604800000'), // 7 days
minSampleSize: parseInt(process.env.MIN_SAMPLE_SIZE || '100'),
confidenceLevel: parseFloat(process.env.CONFIDENCE_LEVEL || '0.95'),
minimumDetectableEffect: parseFloat(process.env.MDE || '0.05'),
maxVariants: parseInt(process.env.MAX_VARIANTS || '10')
},
allocation: {
algorithm: process.env.ALLOCATION_ALGORITHM || 'epsilon-greedy', // 'random', 'epsilon-greedy', 'ucb', 'thompson'
epsilon: parseFloat(process.env.EPSILON || '0.1'),
ucbC: parseFloat(process.env.UCB_C || '2')
},
metrics: {
port: parseInt(process.env.METRICS_PORT || '9105'),
prefix: process.env.METRICS_PREFIX || 'ab_testing_'
},
logging: {
level: process.env.LOG_LEVEL || 'info',
file: process.env.LOG_FILE || 'logs/ab-testing.log'
},
jwt: {
secret: process.env.JWT_SECRET || 'your-ab-testing-secret',
expiresIn: process.env.JWT_EXPIRES_IN || '24h'
}
};

View File

@@ -0,0 +1,99 @@
import express from 'express';
import mongoose from 'mongoose';
import Redis from 'redis';
import { config } from './config/index.js';
import { logger } from './utils/logger.js';
import { setupRoutes } from './routes/index.js';
import { connectRabbitMQ } from './services/messaging.js';
import { startMetricsServer } from './utils/metrics.js';
import { ExperimentScheduler } from './services/experimentScheduler.js';
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
service: 'ab-testing',
timestamp: new Date().toISOString()
});
});
// Setup routes
setupRoutes(app);
// Error handling
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
message: err.message
});
});
// Initialize services
async function initializeServices() {
try {
// Connect to MongoDB
await mongoose.connect(config.mongodb.uri, config.mongodb.options);
logger.info('Connected to MongoDB');
// Connect to Redis
const redisClient = Redis.createClient({ url: config.redis.url });
await redisClient.connect();
logger.info('Connected to Redis');
// Store Redis client globally
app.locals.redis = redisClient;
// Connect to RabbitMQ
const channel = await connectRabbitMQ();
app.locals.rabbitmq = channel;
// Initialize experiment scheduler
const scheduler = new ExperimentScheduler(redisClient);
await scheduler.start();
app.locals.scheduler = scheduler;
// Start metrics server
startMetricsServer(config.metrics.port);
// Start main server
app.listen(config.port, () => {
logger.info(`A/B Testing service listening on port ${config.port}`);
});
} catch (error) {
logger.error('Failed to initialize services:', error);
process.exit(1);
}
}
// Graceful shutdown
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully');
try {
if (app.locals.scheduler) {
await app.locals.scheduler.stop();
}
if (app.locals.redis) {
await app.locals.redis.quit();
}
await mongoose.connection.close();
process.exit(0);
} catch (error) {
logger.error('Error during shutdown:', error);
process.exit(1);
}
});
// Start the service
initializeServices();

View File

@@ -0,0 +1,153 @@
import mongoose from 'mongoose';
const allocationSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
allocationId: {
type: String,
required: true,
unique: true
},
experimentId: {
type: String,
required: true,
index: true
},
userId: {
type: String,
required: true,
index: true
},
variantId: {
type: String,
required: true
},
userContext: {
segments: [String],
attributes: {
type: Map,
of: mongoose.Schema.Types.Mixed
},
device: {
type: {
type: String
},
browser: String,
os: String
},
location: {
country: String,
region: String,
city: String
}
},
exposedAt: {
type: Date,
required: true,
default: Date.now
},
convertedAt: Date,
conversionValue: {
type: Number,
default: 0
},
conversionMetadata: {
source: String,
timestamp: Date,
customData: {
type: Map,
of: mongoose.Schema.Types.Mixed
}
},
metadata: {
sessionId: String,
ipAddress: String,
userAgent: String,
referrer: String
}
}, {
timestamps: true
});
// Compound indexes
allocationSchema.index({ tenantId: 1, experimentId: 1, userId: 1 }, { unique: true });
allocationSchema.index({ experimentId: 1, variantId: 1 });
allocationSchema.index({ experimentId: 1, convertedAt: 1 });
allocationSchema.index({ userId: 1, exposedAt: -1 });
// Multi-tenant indexes
allocationSchema.index({ tenantId: 1, experimentId: 1, userId: 1 }, { unique: true });
allocationSchema.index({ tenantId: 1, experimentId: 1, variantId: 1 });
allocationSchema.index({ tenantId: 1, experimentId: 1, convertedAt: 1 });
allocationSchema.index({ tenantId: 1, userId: 1, exposedAt: -1 });
// Virtual properties
allocationSchema.virtual('isConverted').get(function() {
return this.convertedAt != null;
});
allocationSchema.virtual('timeToConversion').get(function() {
if (!this.convertedAt || !this.exposedAt) return null;
return this.convertedAt - this.exposedAt;
});
// Methods
allocationSchema.methods.recordConversion = function(value = 1, metadata = {}) {
this.convertedAt = new Date();
this.conversionValue = value;
this.conversionMetadata = metadata;
return this.save();
};
// Statics
allocationSchema.statics.findByExperiment = function(experimentId) {
return this.find({ experimentId });
};
allocationSchema.statics.findByUser = function(userId) {
return this.find({ userId }).sort({ exposedAt: -1 });
};
allocationSchema.statics.findConverted = function(experimentId) {
return this.find({
experimentId,
convertedAt: { $exists: true }
});
};
allocationSchema.statics.getConversionStats = function(experimentId, variantId) {
const match = { experimentId };
if (variantId) match.variantId = variantId;
return this.aggregate([
{ $match: match },
{
$group: {
_id: variantId ? null : '$variantId',
participants: { $sum: 1 },
conversions: {
$sum: { $cond: ['$convertedAt', 1, 0] }
},
revenue: {
$sum: { $ifNull: ['$conversionValue', 0] }
},
avgTimeToConversion: {
$avg: {
$cond: [
'$convertedAt',
{ $subtract: ['$convertedAt', '$exposedAt'] },
null
]
}
}
}
}
]);
};
export const Allocation = mongoose.model('Allocation', allocationSchema);

View File

@@ -0,0 +1,267 @@
import mongoose from 'mongoose';
const variantSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
variantId: {
type: String,
required: true
},
name: {
type: String,
required: true
},
description: String,
config: {
type: Map,
of: mongoose.Schema.Types.Mixed
},
allocation: {
percentage: {
type: Number,
min: 0,
max: 100,
default: 0
},
method: {
type: String,
enum: ['fixed', 'dynamic'],
default: 'fixed'
}
},
metrics: {
participants: {
type: Number,
default: 0
},
conversions: {
type: Number,
default: 0
},
revenue: {
type: Number,
default: 0
},
customMetrics: {
type: Map,
of: Number
}
},
statistics: {
conversionRate: Number,
confidenceInterval: {
lower: Number,
upper: Number
},
pValue: Number,
significanceLevel: Number
}
});
const experimentSchema = new mongoose.Schema({
experimentId: {
type: String,
required: true,
unique: true
},
accountId: {
type: String,
required: true,
index: true
},
name: {
type: String,
required: true
},
description: String,
hypothesis: String,
type: {
type: String,
enum: ['ab', 'multivariate', 'bandit'],
default: 'ab'
},
status: {
type: String,
enum: ['draft', 'scheduled', 'running', 'paused', 'completed', 'archived'],
default: 'draft'
},
startDate: Date,
endDate: Date,
scheduledStart: Date,
scheduledEnd: Date,
targetMetric: {
name: String,
type: {
type: String,
enum: ['conversion', 'revenue', 'engagement', 'custom']
},
goalDirection: {
type: String,
enum: ['increase', 'decrease'],
default: 'increase'
}
},
targetAudience: {
filters: [{
field: String,
operator: String,
value: mongoose.Schema.Types.Mixed
}],
segments: [String],
percentage: {
type: Number,
min: 0,
max: 100,
default: 100
}
},
variants: [variantSchema],
control: {
type: String, // variantId of control variant
required: true
},
allocation: {
method: {
type: String,
enum: ['random', 'epsilon-greedy', 'ucb', 'thompson'],
default: 'random'
},
parameters: {
type: Map,
of: mongoose.Schema.Types.Mixed
}
},
requirements: {
minimumSampleSize: {
type: Number,
default: 100
},
statisticalPower: {
type: Number,
default: 0.8
},
confidenceLevel: {
type: Number,
default: 0.95
},
minimumDetectableEffect: {
type: Number,
default: 0.05
}
},
results: {
winner: String, // variantId
completedAt: Date,
summary: String,
recommendations: [String],
statisticalAnalysis: {
type: Map,
of: mongoose.Schema.Types.Mixed
}
},
settings: {
stopOnSignificance: {
type: Boolean,
default: false
},
enableBayesian: {
type: Boolean,
default: true
},
multipleTestingCorrection: {
type: String,
enum: ['none', 'bonferroni', 'benjamini-hochberg'],
default: 'bonferroni'
}
},
metadata: {
tags: [String],
category: String,
priority: {
type: String,
enum: ['low', 'medium', 'high', 'critical'],
default: 'medium'
}
}
}, {
timestamps: true
});
// Indexes
experimentSchema.index({ accountId: 1, status: 1 });
experimentSchema.index({ accountId: 1, createdAt: -1 });
experimentSchema.index({ status: 1, scheduledStart: 1 });
// Multi-tenant indexes
experimentSchema.index({ tenantId: 1, accountId: 1, status: 1 });
experimentSchema.index({ tenantId: 1, accountId: 1, createdAt: -1 });
experimentSchema.index({ tenantId: 1, status: 1, scheduledStart: 1 });
// Methods
experimentSchema.methods.isActive = function() {
return this.status === 'running';
};
experimentSchema.methods.canAllocate = function() {
return this.status === 'running' &&
(!this.endDate || this.endDate > new Date());
};
experimentSchema.methods.getVariant = function(variantId) {
return this.variants.find(v => v.variantId === variantId);
};
experimentSchema.methods.updateMetrics = function(variantId, metrics) {
const variant = this.getVariant(variantId);
if (!variant) return;
// Update standard metrics
if (metrics.participants !== undefined) {
variant.metrics.participants += metrics.participants;
}
if (metrics.conversions !== undefined) {
variant.metrics.conversions += metrics.conversions;
}
if (metrics.revenue !== undefined) {
variant.metrics.revenue += metrics.revenue;
}
// Update custom metrics
if (metrics.custom) {
for (const [key, value] of Object.entries(metrics.custom)) {
const current = variant.metrics.customMetrics.get(key) || 0;
variant.metrics.customMetrics.set(key, current + value);
}
}
// Recalculate statistics
if (variant.metrics.participants > 0) {
variant.statistics.conversionRate = variant.metrics.conversions / variant.metrics.participants;
}
};
// Statics
experimentSchema.statics.findActive = function(accountId) {
return this.find({
accountId,
status: 'running',
$or: [
{ endDate: { $exists: false } },
{ endDate: { $gt: new Date() } }
]
});
};
experimentSchema.statics.findScheduled = function() {
return this.find({
status: 'scheduled',
scheduledStart: { $lte: new Date() }
});
};
export const Experiment = mongoose.model('Experiment', experimentSchema);

View File

@@ -0,0 +1,282 @@
import express from 'express';
import Joi from 'joi';
import { AllocationService } from '../services/allocationService.js';
import { validateRequest } from '../utils/validation.js';
import { logger } from '../utils/logger.js';
const router = express.Router();
// Validation schemas
const allocateSchema = Joi.object({
experimentId: Joi.string().required(),
userId: Joi.string().required(),
context: Joi.object({
sessionId: Joi.string(),
deviceType: Joi.string(),
platform: Joi.string(),
location: Joi.object({
country: Joi.string(),
region: Joi.string(),
city: Joi.string()
}),
userAgent: Joi.string(),
referrer: Joi.string(),
customAttributes: Joi.object()
})
});
const conversionSchema = Joi.object({
experimentId: Joi.string().required(),
userId: Joi.string().required(),
value: Joi.number().default(1),
metadata: Joi.object({
revenue: Joi.number(),
itemId: Joi.string(),
category: Joi.string()
}).default({})
});
const eventSchema = Joi.object({
experimentId: Joi.string().required(),
userId: Joi.string().required(),
event: Joi.object({
type: Joi.string().required(),
name: Joi.string().required(),
value: Joi.any(),
timestamp: Joi.date(),
metadata: Joi.object(),
metrics: Joi.object()
}).required()
});
// Initialize service
let allocationService;
// Allocate user to variant
router.post('/allocate', validateRequest(allocateSchema), async (req, res, next) => {
try {
const { experimentId, userId, context } = req.body;
if (!allocationService) {
allocationService = new AllocationService(req.app.locals.redis);
}
const allocation = await allocationService.allocateUser(
experimentId,
userId,
context
);
if (!allocation) {
return res.json({
allocated: false,
reason: 'User does not meet targeting criteria or is outside traffic percentage'
});
}
res.json({
allocated: true,
variantId: allocation.variantId,
allocationId: allocation.allocationId,
allocatedAt: allocation.allocatedAt
});
} catch (error) {
if (error.message === 'Experiment not found' || error.message === 'Experiment is not active') {
return res.status(404).json({ error: error.message });
}
next(error);
}
});
// Get user allocation
router.get('/allocation/:experimentId/:userId', async (req, res, next) => {
try {
const { experimentId, userId } = req.params;
if (!allocationService) {
allocationService = new AllocationService(req.app.locals.redis);
}
const allocation = await allocationService.getAllocation(experimentId, userId);
if (!allocation) {
return res.status(404).json({
error: 'No allocation found for user'
});
}
res.json({
variantId: allocation.variantId,
allocationId: allocation.allocationId,
allocatedAt: allocation.allocatedAt,
converted: allocation.metrics.converted,
events: allocation.events.length
});
} catch (error) {
next(error);
}
});
// Record conversion
router.post('/conversion', validateRequest(conversionSchema), async (req, res, next) => {
try {
const { experimentId, userId, value, metadata } = req.body;
if (!allocationService) {
allocationService = new AllocationService(req.app.locals.redis);
}
const allocation = await allocationService.recordConversion(
experimentId,
userId,
value,
metadata
);
if (!allocation) {
return res.status(404).json({
error: 'No allocation found for user'
});
}
res.json({
success: true,
variantId: allocation.variantId,
converted: true,
conversionTime: allocation.metrics.conversionTime
});
} catch (error) {
next(error);
}
});
// Record custom event
router.post('/event', validateRequest(eventSchema), async (req, res, next) => {
try {
const { experimentId, userId, event } = req.body;
if (!allocationService) {
allocationService = new AllocationService(req.app.locals.redis);
}
const allocation = await allocationService.recordEvent(
experimentId,
userId,
event
);
if (!allocation) {
return res.status(404).json({
error: 'No allocation found for user'
});
}
res.json({
success: true,
variantId: allocation.variantId,
eventCount: allocation.events.length
});
} catch (error) {
next(error);
}
});
// Batch allocate users
router.post('/batch/allocate', async (req, res, next) => {
try {
const { experimentId, users } = req.body;
if (!Array.isArray(users) || users.length === 0) {
return res.status(400).json({
error: 'Users must be a non-empty array'
});
}
if (!allocationService) {
allocationService = new AllocationService(req.app.locals.redis);
}
const results = [];
const errors = [];
// Process in batches to avoid overwhelming the system
const batchSize = 100;
for (let i = 0; i < users.length; i += batchSize) {
const batch = users.slice(i, i + batchSize);
await Promise.all(
batch.map(async ({ userId, context }) => {
try {
const allocation = await allocationService.allocateUser(
experimentId,
userId,
context || {}
);
results.push({
userId,
allocated: !!allocation,
variantId: allocation?.variantId
});
} catch (error) {
errors.push({
userId,
error: error.message
});
}
})
);
}
res.json({
processed: users.length,
successful: results.length,
failed: errors.length,
results,
errors
});
} catch (error) {
next(error);
}
});
// Get allocation statistics
router.get('/stats/:experimentId', async (req, res, next) => {
try {
const { experimentId } = req.params;
const { variantId, startDate, endDate } = req.query;
const options = {};
if (variantId) options.variantId = variantId;
if (startDate) options.startDate = new Date(startDate);
if (endDate) options.endDate = new Date(endDate);
const allocations = await Allocation.findByExperiment(experimentId, options);
const stats = {
total: allocations.length,
converted: allocations.filter(a => a.metrics.converted).length,
conversionRate: 0,
totalRevenue: 0,
averageEngagement: 0
};
if (stats.total > 0) {
stats.conversionRate = stats.converted / stats.total;
stats.totalRevenue = allocations.reduce((sum, a) => sum + a.metrics.revenue, 0);
stats.averageEngagement = allocations.reduce((sum, a) => sum + a.metrics.engagementScore, 0) / stats.total;
}
res.json(stats);
} catch (error) {
next(error);
}
});
export const allocationRoutes = router;

View File

@@ -0,0 +1,313 @@
import express from 'express';
import Joi from 'joi';
import { v4 as uuidv4 } from 'uuid';
import { Experiment } from '../models/Experiment.js';
import { experimentManager } from '../services/experimentManager.js';
import { validateRequest } from '../utils/validation.js';
import { logger } from '../utils/logger.js';
const router = express.Router();
// Validation schemas
const createExperimentSchema = Joi.object({
name: Joi.string().required(),
description: Joi.string(),
hypothesis: Joi.string(),
type: Joi.string().valid('ab', 'multivariate', 'bandit').default('ab'),
targetMetric: Joi.object({
name: Joi.string().required(),
type: Joi.string().valid('conversion', 'revenue', 'engagement', 'custom').required(),
goalDirection: Joi.string().valid('increase', 'decrease').default('increase')
}).required(),
variants: Joi.array().items(Joi.object({
variantId: Joi.string().required(),
name: Joi.string().required(),
description: Joi.string(),
config: Joi.object(),
allocation: Joi.object({
percentage: Joi.number().min(0).max(100),
method: Joi.string().valid('fixed', 'dynamic').default('fixed')
})
})).min(2).required(),
control: Joi.string().required(),
allocation: Joi.object({
method: Joi.string().valid('random', 'epsilon-greedy', 'ucb', 'thompson').default('random'),
parameters: Joi.object()
}),
targetAudience: Joi.object({
filters: Joi.array().items(Joi.object({
field: Joi.string().required(),
operator: Joi.string().required(),
value: Joi.any().required()
})),
segments: Joi.array().items(Joi.string()),
percentage: Joi.number().min(0).max(100).default(100)
}),
requirements: Joi.object({
minimumSampleSize: Joi.number().min(1),
statisticalPower: Joi.number().min(0).max(1),
confidenceLevel: Joi.number().min(0).max(1),
minimumDetectableEffect: Joi.number().min(0).max(1)
}),
settings: Joi.object({
stopOnSignificance: Joi.boolean(),
enableBayesian: Joi.boolean(),
multipleTestingCorrection: Joi.string().valid('none', 'bonferroni', 'benjamini-hochberg')
}),
scheduledStart: Joi.date(),
scheduledEnd: Joi.date(),
metadata: Joi.object({
tags: Joi.array().items(Joi.string()),
category: Joi.string(),
priority: Joi.string().valid('low', 'medium', 'high', 'critical')
})
});
const updateExperimentSchema = Joi.object({
name: Joi.string(),
description: Joi.string(),
hypothesis: Joi.string(),
targetMetric: Joi.object({
name: Joi.string(),
type: Joi.string().valid('conversion', 'revenue', 'engagement', 'custom'),
goalDirection: Joi.string().valid('increase', 'decrease')
}),
targetAudience: Joi.object({
filters: Joi.array().items(Joi.object({
field: Joi.string().required(),
operator: Joi.string().required(),
value: Joi.any().required()
})),
segments: Joi.array().items(Joi.string()),
percentage: Joi.number().min(0).max(100)
}),
requirements: Joi.object({
minimumSampleSize: Joi.number().min(1),
statisticalPower: Joi.number().min(0).max(1),
confidenceLevel: Joi.number().min(0).max(1),
minimumDetectableEffect: Joi.number().min(0).max(1)
}),
settings: Joi.object({
stopOnSignificance: Joi.boolean(),
enableBayesian: Joi.boolean(),
multipleTestingCorrection: Joi.string().valid('none', 'bonferroni', 'benjamini-hochberg')
}),
scheduledStart: Joi.date(),
scheduledEnd: Joi.date(),
metadata: Joi.object({
tags: Joi.array().items(Joi.string()),
category: Joi.string(),
priority: Joi.string().valid('low', 'medium', 'high', 'critical')
})
});
// List experiments
router.get('/', async (req, res, next) => {
try {
const { accountId } = req.auth;
const { status, page = 1, limit = 20 } = req.query;
const query = { accountId };
if (status) query.status = status;
const experiments = await Experiment.find(query)
.sort({ createdAt: -1 })
.limit(limit * 1)
.skip((page - 1) * limit)
.lean();
const total = await Experiment.countDocuments(query);
res.json({
experiments,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (error) {
next(error);
}
});
// Get experiment by ID
router.get('/:id', async (req, res, next) => {
try {
const { accountId } = req.auth;
const { id } = req.params;
const experiment = await Experiment.findOne({
experimentId: id,
accountId
}).lean();
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
res.json(experiment);
} catch (error) {
next(error);
}
});
// Create experiment
router.post('/', validateRequest(createExperimentSchema), async (req, res, next) => {
try {
const { accountId } = req.auth;
const experimentData = req.body;
experimentData.accountId = accountId;
// Create experiment using ExperimentManager
const experiment = await experimentManager.createExperiment(experimentData);
res.status(201).json(experiment);
} catch (error) {
next(error);
}
});
// Update experiment
router.put('/:id', validateRequest(updateExperimentSchema), async (req, res, next) => {
try {
const { accountId } = req.auth;
const { id } = req.params;
const updates = req.body;
const experiment = await Experiment.findOne({
experimentId: id,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
// Prevent updates to running experiments
if (experiment.status === 'running') {
return res.status(400).json({
error: 'Cannot update a running experiment. Pause it first.'
});
}
// Apply updates
Object.assign(experiment, updates);
await experiment.save();
logger.info(`Updated experiment ${id} for account ${accountId}`);
res.json(experiment);
} catch (error) {
next(error);
}
});
// Delete experiment
router.delete('/:id', async (req, res, next) => {
try {
const { accountId } = req.auth;
const { id } = req.params;
const experiment = await Experiment.findOne({
experimentId: id,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
// Only allow deletion of draft or archived experiments
if (!['draft', 'archived'].includes(experiment.status)) {
return res.status(400).json({
error: 'Can only delete draft or archived experiments'
});
}
await experiment.remove();
logger.info(`Deleted experiment ${id} for account ${accountId}`);
res.json({ message: 'Experiment deleted successfully' });
} catch (error) {
next(error);
}
});
// Start experiment
router.post('/:id/start', async (req, res, next) => {
try {
const { accountId } = req.auth;
const { id } = req.params;
// Verify ownership
const experiment = await Experiment.findOne({
experimentId: id,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
const result = await experimentManager.startExperiment(id);
res.json(result);
} catch (error) {
next(error);
}
});
// Stop experiment
router.post('/:id/stop', async (req, res, next) => {
try {
const { accountId } = req.auth;
const { id } = req.params;
const { reason } = req.body;
// Verify ownership
const experiment = await Experiment.findOne({
experimentId: id,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
const result = await experimentManager.stopExperiment(id, reason);
res.json(result);
} catch (error) {
next(error);
}
});
// Get experiment status
router.get('/:id/status', async (req, res, next) => {
try {
const { accountId } = req.auth;
const { id } = req.params;
// Verify ownership
const experiment = await Experiment.findOne({
experimentId: id,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
const status = await experimentManager.getExperimentStatus(id);
res.json(status);
} catch (error) {
next(error);
}
});
export const experimentRoutes = router;

View File

@@ -0,0 +1,31 @@
import { experimentRoutes } from './experiments.js';
import { allocationRoutes } from './allocations.js';
import { resultRoutes } from './results.js';
import { trackingRoutes } from './tracking.js';
import { authMiddleware } from '../utils/auth.js';
export const setupRoutes = (app) => {
// Apply authentication middleware
app.use('/api', authMiddleware);
// Mount route groups
app.use('/api/experiments', experimentRoutes);
app.use('/api/allocations', allocationRoutes);
app.use('/api/results', resultRoutes);
app.use('/api/tracking', trackingRoutes);
// Root endpoint
app.get('/', (req, res) => {
res.json({
service: 'A/B Testing Service',
version: '1.0.0',
endpoints: {
experiments: '/api/experiments',
allocations: '/api/allocations',
results: '/api/results',
tracking: '/api/tracking',
health: '/health'
}
});
});
};

View File

@@ -0,0 +1,463 @@
import express from 'express';
import { Experiment } from '../models/Experiment.js';
import { Allocation } from '../models/Allocation.js';
import { StatisticalAnalyzer } from '../services/statisticalAnalyzer.js';
import { AllocationManager } from '../algorithms/index.js';
import { logger } from '../utils/logger.js';
const router = express.Router();
// Get experiment results
router.get('/:experimentId', async (req, res, next) => {
try {
const { experimentId } = req.params;
const { accountId } = req.auth;
const experiment = await Experiment.findOne({
experimentId,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
// Get variant statistics
const stats = await Allocation.getExperimentStats(experimentId);
// Perform statistical analysis
const analyzer = new StatisticalAnalyzer();
const analysis = await analyzer.analyze(experiment, stats);
// Get allocation algorithm details
const allocationManager = new AllocationManager(req.app.locals.redis);
const algorithm = await allocationManager.getAlgorithm(experiment);
const algorithmStats = algorithm.getStats ? algorithm.getStats() : null;
const probabilities = algorithm.getProbabilities ? algorithm.getProbabilities(experiment) : null;
res.json({
experiment: {
experimentId: experiment.experimentId,
name: experiment.name,
status: experiment.status,
type: experiment.type,
startDate: experiment.startDate,
endDate: experiment.endDate,
targetMetric: experiment.targetMetric
},
results: experiment.results,
analysis,
algorithmStats,
allocationProbabilities: probabilities,
rawStats: stats
});
} catch (error) {
next(error);
}
});
// Get real-time metrics
router.get('/:experimentId/metrics', async (req, res, next) => {
try {
const { experimentId } = req.params;
const { accountId } = req.auth;
const { interval = '1h', metric = 'conversion_rate' } = req.query;
const experiment = await Experiment.findOne({
experimentId,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
// Calculate time buckets
const now = new Date();
const intervalMs = parseInterval(interval);
const buckets = generateTimeBuckets(experiment.startDate || now, now, intervalMs);
// Aggregate metrics by time bucket
const timeSeriesData = await Allocation.aggregate([
{ $match: { experimentId } },
{
$project: {
variantId: 1,
allocatedAt: 1,
converted: '$metrics.converted',
revenue: '$metrics.revenue',
timeBucket: {
$subtract: [
'$allocatedAt',
{ $mod: [{ $toLong: '$allocatedAt' }, intervalMs] }
]
}
}
},
{
$group: {
_id: {
variantId: '$variantId',
timeBucket: '$timeBucket'
},
participants: { $sum: 1 },
conversions: { $sum: { $cond: ['$converted', 1, 0] } },
revenue: { $sum: '$revenue' }
}
},
{
$project: {
variantId: '$_id.variantId',
timestamp: '$_id.timeBucket',
participants: 1,
conversions: 1,
revenue: 1,
conversionRate: {
$cond: [
{ $gt: ['$participants', 0] },
{ $divide: ['$conversions', '$participants'] },
0
]
},
revenuePerUser: {
$cond: [
{ $gt: ['$participants', 0] },
{ $divide: ['$revenue', '$participants'] },
0
]
}
}
},
{ $sort: { timestamp: 1 } }
]);
// Format response
const variants = {};
for (const variant of experiment.variants) {
variants[variant.variantId] = {
name: variant.name,
data: []
};
}
// Populate time series data
for (const bucket of buckets) {
for (const variant of experiment.variants) {
const dataPoint = timeSeriesData.find(
d => d.variantId === variant.variantId &&
d.timestamp.getTime() === bucket.getTime()
);
const value = dataPoint ? dataPoint[metric] || 0 : 0;
variants[variant.variantId].data.push({
timestamp: bucket,
value
});
}
}
res.json({
metric,
interval,
startTime: buckets[0],
endTime: buckets[buckets.length - 1],
variants
});
} catch (error) {
next(error);
}
});
// Get segment analysis
router.get('/:experimentId/segments', async (req, res, next) => {
try {
const { experimentId } = req.params;
const { accountId } = req.auth;
const { segmentBy = 'deviceType' } = req.query;
const experiment = await Experiment.findOne({
experimentId,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
// Aggregate by segment
const segmentPath = `context.${segmentBy}`;
const segmentData = await Allocation.aggregate([
{ $match: { experimentId } },
{
$group: {
_id: {
variantId: '$variantId',
segment: `$${segmentPath}`
},
participants: { $sum: 1 },
conversions: { $sum: { $cond: ['$metrics.converted', 1, 0] } },
revenue: { $sum: '$metrics.revenue' }
}
},
{
$project: {
variantId: '$_id.variantId',
segment: '$_id.segment',
participants: 1,
conversions: 1,
revenue: 1,
conversionRate: {
$cond: [
{ $gt: ['$participants', 0] },
{ $divide: ['$conversions', '$participants'] },
0
]
}
}
}
]);
// Organize by segment
const segments = {};
for (const data of segmentData) {
if (!data.segment) continue;
if (!segments[data.segment]) {
segments[data.segment] = {
variants: {}
};
}
segments[data.segment].variants[data.variantId] = {
participants: data.participants,
conversions: data.conversions,
conversionRate: data.conversionRate,
revenue: data.revenue
};
}
// Perform statistical analysis per segment
const analyzer = new StatisticalAnalyzer();
const segmentAnalysis = {};
for (const [segment, segmentData] of Object.entries(segments)) {
const variantStats = experiment.variants.map(v => {
const stats = segmentData.variants[v.variantId] || {
participants: 0,
conversions: 0,
revenue: 0
};
return {
variantId: v.variantId,
...stats
};
});
if (variantStats.some(v => v.participants > 0)) {
segmentAnalysis[segment] = await analyzer.analyze(experiment, variantStats);
}
}
res.json({
segmentBy,
segments: segmentAnalysis
});
} catch (error) {
next(error);
}
});
// Get funnel analysis
router.get('/:experimentId/funnel', async (req, res, next) => {
try {
const { experimentId } = req.params;
const { accountId } = req.auth;
const { steps } = req.query;
if (!steps) {
return res.status(400).json({
error: 'Funnel steps required (comma-separated event names)'
});
}
const experiment = await Experiment.findOne({
experimentId,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
const funnelSteps = steps.split(',').map(s => s.trim());
// Get all allocations with events
const allocations = await Allocation.find({
experimentId,
'events.0': { $exists: true }
}).lean();
// Calculate funnel for each variant
const funnelData = {};
for (const variant of experiment.variants) {
const variantAllocations = allocations.filter(
a => a.variantId === variant.variantId
);
const stepCounts = [];
let remainingUsers = new Set(variantAllocations.map(a => a.userId));
for (const step of funnelSteps) {
const usersAtStep = new Set();
for (const allocation of variantAllocations) {
if (!remainingUsers.has(allocation.userId)) continue;
const hasEvent = allocation.events.some(
e => e.eventName === step
);
if (hasEvent) {
usersAtStep.add(allocation.userId);
}
}
stepCounts.push({
step,
users: usersAtStep.size,
dropoff: remainingUsers.size - usersAtStep.size,
conversionRate: remainingUsers.size > 0
? usersAtStep.size / remainingUsers.size
: 0
});
remainingUsers = usersAtStep;
}
funnelData[variant.variantId] = {
name: variant.name,
totalUsers: variantAllocations.length,
steps: stepCounts,
overallConversion: variantAllocations.length > 0 && stepCounts.length > 0
? stepCounts[stepCounts.length - 1].users / variantAllocations.length
: 0
};
}
res.json({
funnel: funnelSteps,
variants: funnelData
});
} catch (error) {
next(error);
}
});
// Download results as CSV
router.get('/:experimentId/export', async (req, res, next) => {
try {
const { experimentId } = req.params;
const { accountId } = req.auth;
const { format = 'csv' } = req.query;
const experiment = await Experiment.findOne({
experimentId,
accountId
});
if (!experiment) {
return res.status(404).json({ error: 'Experiment not found' });
}
// Get all allocations
const allocations = await Allocation.find({ experimentId }).lean();
if (format === 'csv') {
// Generate CSV
const csv = generateCSV(experiment, allocations);
res.setHeader('Content-Type', 'text/csv');
res.setHeader(
'Content-Disposition',
`attachment; filename="experiment_${experimentId}_results.csv"`
);
res.send(csv);
} else {
res.status(400).json({ error: 'Unsupported format' });
}
} catch (error) {
next(error);
}
});
// Helper functions
function parseInterval(interval) {
const unit = interval.slice(-1);
const value = parseInt(interval.slice(0, -1));
switch (unit) {
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000;
default: return 60 * 60 * 1000; // Default to 1 hour
}
}
function generateTimeBuckets(start, end, intervalMs) {
const buckets = [];
let current = new Date(Math.floor(start.getTime() / intervalMs) * intervalMs);
while (current <= end) {
buckets.push(new Date(current));
current = new Date(current.getTime() + intervalMs);
}
return buckets;
}
function generateCSV(experiment, allocations) {
const lines = [];
// Header
lines.push([
'User ID',
'Variant ID',
'Variant Name',
'Allocated At',
'Converted',
'Conversion Time',
'Revenue',
'Events Count',
'Device Type',
'Platform'
].join(','));
// Data rows
for (const allocation of allocations) {
const variant = experiment.variants.find(v => v.variantId === allocation.variantId);
lines.push([
allocation.userId,
allocation.variantId,
variant?.name || 'Unknown',
allocation.allocatedAt.toISOString(),
allocation.metrics.converted ? 'Yes' : 'No',
allocation.metrics.conversionTime?.toISOString() || '',
allocation.metrics.revenue || 0,
allocation.events.length,
allocation.context?.deviceType || '',
allocation.context?.platform || ''
].join(','));
}
return lines.join('\n');
}
export const resultRoutes = router;

View File

@@ -0,0 +1,239 @@
import express from 'express';
import Joi from 'joi';
import { experimentManager } from '../services/experimentManager.js';
import { validateRequest } from '../utils/validation.js';
import { logger } from '../utils/logger.js';
const router = express.Router();
// Validation schemas
const allocateUserSchema = Joi.object({
experimentId: Joi.string().required(),
userId: Joi.string().required(),
userContext: Joi.object({
segments: Joi.array().items(Joi.string()),
attributes: Joi.object(),
device: Joi.object({
type: Joi.string(),
browser: Joi.string(),
os: Joi.string()
}),
location: Joi.object({
country: Joi.string(),
region: Joi.string(),
city: Joi.string()
})
}).default({})
});
const recordConversionSchema = Joi.object({
experimentId: Joi.string().required(),
userId: Joi.string().required(),
value: Joi.number().positive().default(1),
metadata: Joi.object({
source: Joi.string(),
timestamp: Joi.date().iso(),
customData: Joi.object()
}).default({})
});
const batchAllocateSchema = Joi.object({
experimentId: Joi.string().required(),
users: Joi.array().items(Joi.object({
userId: Joi.string().required(),
userContext: Joi.object().default({})
})).max(1000).required()
});
const batchConversionSchema = Joi.object({
experimentId: Joi.string().required(),
conversions: Joi.array().items(Joi.object({
userId: Joi.string().required(),
value: Joi.number().positive().default(1),
metadata: Joi.object().default({})
})).max(1000).required()
});
// Allocate user to experiment variant
router.post('/allocate', validateRequest(allocateUserSchema), async (req, res, next) => {
try {
const { experimentId, userId, userContext } = req.body;
const allocation = await experimentManager.allocateUser(
experimentId,
userId,
userContext
);
if (!allocation) {
return res.status(400).json({
error: 'User not eligible for experiment or experiment not running'
});
}
res.json({
allocation: {
experimentId: allocation.experimentId,
userId: allocation.userId,
variantId: allocation.variantId,
exposedAt: allocation.exposedAt
}
});
} catch (error) {
logger.error('Failed to allocate user', { error: error.message });
next(error);
}
});
// Record conversion
router.post('/convert', validateRequest(recordConversionSchema), async (req, res, next) => {
try {
const { experimentId, userId, value, metadata } = req.body;
const result = await experimentManager.recordConversion(
experimentId,
userId,
value,
metadata
);
if (!result) {
return res.status(400).json({
error: 'No allocation found for user in experiment'
});
}
res.json({
success: true,
conversion: {
experimentId: result.experimentId,
userId: result.userId,
variantId: result.variantId,
convertedAt: result.convertedAt,
value: result.conversionValue
}
});
} catch (error) {
logger.error('Failed to record conversion', { error: error.message });
next(error);
}
});
// Batch allocate users
router.post('/batch/allocate', validateRequest(batchAllocateSchema), async (req, res, next) => {
try {
const { experimentId, users } = req.body;
const results = await Promise.allSettled(
users.map(user =>
experimentManager.allocateUser(
experimentId,
user.userId,
user.userContext
)
)
);
const allocations = [];
const errors = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
allocations.push({
userId: users[index].userId,
variantId: result.value.variantId,
exposedAt: result.value.exposedAt
});
} else {
errors.push({
userId: users[index].userId,
error: result.reason?.message || 'Allocation failed'
});
}
});
res.json({
experimentId,
allocations,
errors,
summary: {
total: users.length,
allocated: allocations.length,
failed: errors.length
}
});
} catch (error) {
logger.error('Batch allocation failed', { error: error.message });
next(error);
}
});
// Batch record conversions
router.post('/batch/convert', validateRequest(batchConversionSchema), async (req, res, next) => {
try {
const { experimentId, conversions } = req.body;
const results = await Promise.allSettled(
conversions.map(conv =>
experimentManager.recordConversion(
experimentId,
conv.userId,
conv.value,
conv.metadata
)
)
);
const recorded = [];
const errors = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
recorded.push({
userId: conversions[index].userId,
value: conversions[index].value,
convertedAt: result.value.convertedAt
});
} else {
errors.push({
userId: conversions[index].userId,
error: result.reason?.message || 'Conversion recording failed'
});
}
});
res.json({
experimentId,
recorded,
errors,
summary: {
total: conversions.length,
recorded: recorded.length,
failed: errors.length
}
});
} catch (error) {
logger.error('Batch conversion recording failed', { error: error.message });
next(error);
}
});
// Get user's active experiments
router.get('/user/:userId/experiments', async (req, res, next) => {
try {
const { userId } = req.params;
const { active = true } = req.query;
// This would typically query the Allocation model
// For now, return a placeholder
res.json({
userId,
experiments: [],
message: 'Implementation pending - would return user\'s experiment allocations'
});
} catch (error) {
next(error);
}
});
export const trackingRoutes = router;

View File

@@ -0,0 +1,365 @@
import { v4 as uuidv4 } from 'uuid';
import murmurhash from 'murmurhash';
import { Experiment } from '../models/Experiment.js';
import { Allocation } from '../models/Allocation.js';
import { AllocationManager } from '../algorithms/index.js';
import { publishEvent } from './messaging.js';
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
export class AllocationService {
constructor(redisClient) {
this.redis = redisClient;
this.allocationManager = new AllocationManager(redisClient);
this.cache = new Map();
}
/**
* Allocate a user to a variant
*/
async allocateUser(experimentId, userId, context = {}) {
try {
// Check cache first
const cacheKey = `${experimentId}:${userId}`;
const cached = await this.getCachedAllocation(cacheKey);
if (cached) {
return cached;
}
// Get experiment
const experiment = await Experiment.findOne({ experimentId });
if (!experiment) {
throw new Error('Experiment not found');
}
if (!experiment.canAllocate()) {
throw new Error('Experiment is not active');
}
// Check if user already has allocation (sticky assignment)
const existingAllocation = await Allocation.getUserAllocation(experimentId, userId);
if (existingAllocation) {
await this.cacheAllocation(cacheKey, existingAllocation);
return existingAllocation;
}
// Check if user meets target audience criteria
if (!this.meetsTargetCriteria(experiment, userId, context)) {
return null;
}
// Check traffic percentage
if (!this.isInTrafficPercentage(experiment, userId)) {
return null;
}
// Get allocation algorithm
const algorithm = await this.allocationManager.getAlgorithm(experiment);
// Determine variant
const variant = algorithm.allocate(experiment, userId, context);
// Create allocation record
const allocation = new Allocation({
allocationId: uuidv4(),
experimentId,
userId,
variantId: variant.variantId,
method: algorithm.name,
sticky: true,
context: {
sessionId: context.sessionId,
deviceType: context.deviceType,
platform: context.platform,
location: context.location,
userAgent: context.userAgent,
referrer: context.referrer,
customAttributes: context.customAttributes
}
});
await allocation.save();
// Update variant metrics
experiment.updateMetrics(variant.variantId, { participants: 1 });
await experiment.save();
// Cache allocation
await this.cacheAllocation(cacheKey, allocation);
// Publish allocation event
await publishEvent('allocation.created', {
experimentId,
userId,
variantId: variant.variantId,
allocatedAt: allocation.allocatedAt
});
logger.debug(`Allocated user ${userId} to variant ${variant.variantId} in experiment ${experimentId}`);
return allocation;
} catch (error) {
logger.error('Allocation failed:', error);
throw error;
}
}
/**
* Get allocation for a user
*/
async getAllocation(experimentId, userId) {
// Check cache
const cacheKey = `${experimentId}:${userId}`;
const cached = await this.getCachedAllocation(cacheKey);
if (cached) {
return cached;
}
// Get from database
const allocation = await Allocation.getUserAllocation(experimentId, userId);
if (allocation) {
await this.cacheAllocation(cacheKey, allocation);
}
return allocation;
}
/**
* Record conversion event
*/
async recordConversion(experimentId, userId, value = 1, metadata = {}) {
const allocation = await this.getAllocation(experimentId, userId);
if (!allocation) {
logger.warn(`No allocation found for user ${userId} in experiment ${experimentId}`);
return null;
}
// Update allocation metrics
allocation.recordEvent({
type: 'conversion',
name: 'conversion',
value,
metadata,
revenue: metadata.revenue
});
await allocation.save();
// Update experiment metrics
const experiment = await Experiment.findOne({ experimentId });
if (experiment) {
experiment.updateMetrics(allocation.variantId, {
conversions: 1,
revenue: metadata.revenue || 0
});
await experiment.save();
// Update algorithm if using adaptive allocation
if (['epsilon-greedy', 'ucb', 'thompson'].includes(experiment.allocation.method)) {
const algorithm = await this.allocationManager.getAlgorithm(experiment);
algorithm.update(experiment, allocation.variantId, 1); // Reward = 1 for conversion
await this.allocationManager.saveAlgorithmState(experimentId, algorithm);
}
}
// Publish conversion event
await publishEvent('conversion.recorded', {
experimentId,
userId,
variantId: allocation.variantId,
value,
metadata
});
return allocation;
}
/**
* Record custom event
*/
async recordEvent(experimentId, userId, event) {
const allocation = await this.getAllocation(experimentId, userId);
if (!allocation) {
logger.warn(`No allocation found for user ${userId} in experiment ${experimentId}`);
return null;
}
allocation.recordEvent(event);
await allocation.save();
// Update custom metrics if defined
if (event.metrics) {
const experiment = await Experiment.findOne({ experimentId });
if (experiment) {
experiment.updateMetrics(allocation.variantId, {
custom: event.metrics
});
await experiment.save();
}
}
return allocation;
}
/**
* Check if user meets target audience criteria
*/
meetsTargetCriteria(experiment, userId, context) {
const { targetAudience } = experiment;
if (!targetAudience || !targetAudience.filters || targetAudience.filters.length === 0) {
return true; // No filters, all users qualify
}
// Check each filter
for (const filter of targetAudience.filters) {
const contextValue = this.getValueFromContext(context, filter.field);
if (!this.evaluateFilter(contextValue, filter.operator, filter.value)) {
return false;
}
}
return true;
}
/**
* Check if user is in traffic percentage
*/
isInTrafficPercentage(experiment, userId) {
const percentage = experiment.targetAudience?.percentage || 100;
if (percentage >= 100) {
return true;
}
// Use consistent hashing to determine if user is in percentage
const hash = murmurhash.v3(`${experiment.experimentId}:${userId}`);
const normalizedHash = (hash % 10000) / 100; // 0-99.99
return normalizedHash < percentage;
}
/**
* Get value from context object
*/
getValueFromContext(context, field) {
const parts = field.split('.');
let value = context;
for (const part of parts) {
if (value && typeof value === 'object') {
value = value[part];
} else {
return undefined;
}
}
return value;
}
/**
* Evaluate filter condition
*/
evaluateFilter(value, operator, targetValue) {
switch (operator) {
case 'equals':
case '==':
return value == targetValue;
case 'not_equals':
case '!=':
return value != targetValue;
case 'greater_than':
case '>':
return value > targetValue;
case 'greater_than_or_equal':
case '>=':
return value >= targetValue;
case 'less_than':
case '<':
return value < targetValue;
case 'less_than_or_equal':
case '<=':
return value <= targetValue;
case 'contains':
return String(value).includes(String(targetValue));
case 'not_contains':
return !String(value).includes(String(targetValue));
case 'in':
return Array.isArray(targetValue) && targetValue.includes(value);
case 'not_in':
return Array.isArray(targetValue) && !targetValue.includes(value);
case 'exists':
return value !== undefined && value !== null;
case 'not_exists':
return value === undefined || value === null;
default:
logger.warn(`Unknown filter operator: ${operator}`);
return true;
}
}
/**
* Get cached allocation
*/
async getCachedAllocation(key) {
try {
const cached = await this.redis.get(`${config.redis.prefix}allocation:${key}`);
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
logger.error('Cache get failed:', error);
}
return null;
}
/**
* Cache allocation
*/
async cacheAllocation(key, allocation) {
try {
await this.redis.setex(
`${config.redis.prefix}allocation:${key}`,
config.redis.ttl,
JSON.stringify({
allocationId: allocation.allocationId,
variantId: allocation.variantId,
allocatedAt: allocation.allocatedAt
})
);
} catch (error) {
logger.error('Cache set failed:', error);
}
}
/**
* Clear cache for experiment
*/
async clearExperimentCache(experimentId) {
try {
const pattern = `${config.redis.prefix}allocation:${experimentId}:*`;
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
logger.info(`Cleared ${keys.length} cached allocations for experiment ${experimentId}`);
}
} catch (error) {
logger.error('Cache clear failed:', error);
}
}
}

View File

@@ -0,0 +1,762 @@
import { Experiment } from '../models/Experiment.js';
import { Allocation } from '../models/Allocation.js';
import { statisticalAnalyzer } from './statisticalAnalyzer.js';
import { logger } from '../utils/logger.js';
import { v4 as uuidv4 } from 'uuid';
/**
* Complete A/B Testing Experiment Manager
*/
export class ExperimentManager {
constructor() {
this.activeExperiments = new Map();
this.allocationStrategies = new Map();
}
/**
* Create a new experiment
*/
async createExperiment(data) {
try {
// Validate variant allocation
const totalAllocation = data.variants.reduce((sum, v) => sum + v.allocation.percentage, 0);
if (Math.abs(totalAllocation - 100) > 0.01) {
throw new Error('Variant allocations must sum to 100%');
}
// Generate experiment ID
const experimentId = `exp_${uuidv4()}`;
// Ensure control variant exists
if (!data.variants.find(v => v.variantId === data.control)) {
throw new Error('Control variant not found in variants list');
}
// Calculate minimum sample size
const minSampleSize = this.calculateSampleSize(data.requirements);
const experiment = new Experiment({
...data,
experimentId,
requirements: {
...data.requirements,
minimumSampleSize: minSampleSize
}
});
await experiment.save();
logger.info('Experiment created', {
experimentId,
name: data.name,
type: data.type
});
return experiment;
} catch (error) {
logger.error('Failed to create experiment', error);
throw error;
}
}
/**
* Start an experiment
*/
async startExperiment(experimentId) {
try {
const experiment = await Experiment.findOne({ experimentId });
if (!experiment) {
throw new Error('Experiment not found');
}
if (experiment.status !== 'draft' && experiment.status !== 'scheduled') {
throw new Error(`Cannot start experiment in ${experiment.status} status`);
}
// Validate experiment setup
this.validateExperiment(experiment);
// Update status
experiment.status = 'running';
experiment.startDate = new Date();
await experiment.save();
// Cache active experiment
this.activeExperiments.set(experimentId, experiment);
logger.info('Experiment started', { experimentId });
return experiment;
} catch (error) {
logger.error('Failed to start experiment', error);
throw error;
}
}
/**
* Stop an experiment
*/
async stopExperiment(experimentId, reason = 'manual') {
try {
const experiment = await Experiment.findOne({ experimentId });
if (!experiment) {
throw new Error('Experiment not found');
}
if (experiment.status !== 'running') {
throw new Error(`Cannot stop experiment in ${experiment.status} status`);
}
// Analyze results
const analysis = await this.analyzeExperiment(experimentId);
// Update experiment
experiment.status = 'completed';
experiment.endDate = new Date();
experiment.results = {
winner: analysis.winner,
completedAt: new Date(),
summary: analysis.summary,
recommendations: analysis.recommendations,
statisticalAnalysis: analysis.statistics
};
await experiment.save();
// Remove from cache
this.activeExperiments.delete(experimentId);
logger.info('Experiment stopped', {
experimentId,
reason,
winner: analysis.winner
});
return experiment;
} catch (error) {
logger.error('Failed to stop experiment', error);
throw error;
}
}
/**
* Allocate user to variant
*/
async allocateUser(experimentId, userId, userContext = {}) {
try {
// Check if user already allocated
const existingAllocation = await Allocation.findOne({
experimentId,
userId
});
if (existingAllocation) {
return existingAllocation;
}
// Get experiment
const experiment = this.activeExperiments.get(experimentId) ||
await Experiment.findOne({ experimentId, status: 'running' });
if (!experiment || !experiment.canAllocate()) {
return null;
}
// Check audience targeting
if (!this.matchesTargetAudience(userContext, experiment.targetAudience)) {
return null;
}
// Allocate variant
const variantId = await this.selectVariant(experiment, userContext);
// Create allocation record
const allocation = new Allocation({
allocationId: `alloc_${uuidv4()}`,
experimentId,
userId,
variantId,
userContext,
exposedAt: new Date()
});
await allocation.save();
// Update variant metrics
await this.updateVariantMetrics(experimentId, variantId, {
participants: 1
});
logger.info('User allocated to variant', {
experimentId,
userId,
variantId
});
return allocation;
} catch (error) {
logger.error('Failed to allocate user', error);
throw error;
}
}
/**
* Record conversion event
*/
async recordConversion(experimentId, userId, value = 1, metadata = {}) {
try {
const allocation = await Allocation.findOne({
experimentId,
userId
});
if (!allocation) {
logger.warn('No allocation found for conversion', {
experimentId,
userId
});
return null;
}
// Update allocation
allocation.convertedAt = new Date();
allocation.conversionValue = value;
allocation.conversionMetadata = metadata;
await allocation.save();
// Update variant metrics
await this.updateVariantMetrics(experimentId, allocation.variantId, {
conversions: 1,
revenue: value
});
// Check for early stopping
await this.checkEarlyStopping(experimentId);
logger.info('Conversion recorded', {
experimentId,
userId,
variantId: allocation.variantId,
value
});
return allocation;
} catch (error) {
logger.error('Failed to record conversion', error);
throw error;
}
}
/**
* Select variant based on allocation strategy
*/
async selectVariant(experiment, userContext) {
const method = experiment.allocation.method;
switch (method) {
case 'random':
return this.randomAllocation(experiment);
case 'epsilon-greedy':
return this.epsilonGreedyAllocation(experiment);
case 'ucb':
return this.ucbAllocation(experiment);
case 'thompson':
return this.thompsonSamplingAllocation(experiment);
default:
return this.randomAllocation(experiment);
}
}
/**
* Random allocation
*/
randomAllocation(experiment) {
const random = Math.random() * 100;
let cumulative = 0;
for (const variant of experiment.variants) {
cumulative += variant.allocation.percentage;
if (random <= cumulative) {
return variant.variantId;
}
}
// Fallback to control
return experiment.control;
}
/**
* Epsilon-greedy allocation
*/
async epsilonGreedyAllocation(experiment) {
const epsilon = experiment.allocation.parameters?.epsilon || 0.1;
if (Math.random() < epsilon) {
// Explore: random allocation
return this.randomAllocation(experiment);
} else {
// Exploit: choose best performing variant
let bestVariant = experiment.control;
let bestRate = 0;
for (const variant of experiment.variants) {
const rate = variant.statistics?.conversionRate || 0;
if (rate > bestRate) {
bestRate = rate;
bestVariant = variant.variantId;
}
}
return bestVariant;
}
}
/**
* Upper Confidence Bound allocation
*/
async ucbAllocation(experiment) {
const c = experiment.allocation.parameters?.c || 2;
const totalParticipants = experiment.variants.reduce(
(sum, v) => sum + v.metrics.participants, 0
);
let bestVariant = experiment.control;
let bestScore = -Infinity;
for (const variant of experiment.variants) {
const n = variant.metrics.participants || 1;
const rate = variant.statistics?.conversionRate || 0;
// UCB score
const score = rate + Math.sqrt(c * Math.log(totalParticipants + 1) / n);
if (score > bestScore) {
bestScore = score;
bestVariant = variant.variantId;
}
}
return bestVariant;
}
/**
* Thompson Sampling allocation
*/
async thompsonSamplingAllocation(experiment) {
let bestVariant = experiment.control;
let bestSample = -Infinity;
for (const variant of experiment.variants) {
const successes = variant.metrics.conversions || 1;
const failures = Math.max(1, variant.metrics.participants - successes);
// Sample from Beta distribution
const sample = this.sampleBeta(successes, failures);
if (sample > bestSample) {
bestSample = sample;
bestVariant = variant.variantId;
}
}
return bestVariant;
}
/**
* Sample from Beta distribution
*/
sampleBeta(alpha, beta) {
// Simple approximation using Gamma distribution
const gammaAlpha = this.sampleGamma(alpha);
const gammaBeta = this.sampleGamma(beta);
return gammaAlpha / (gammaAlpha + gammaBeta);
}
/**
* Sample from Gamma distribution
*/
sampleGamma(shape) {
// Marsaglia and Tsang method approximation
if (shape < 1) {
return this.sampleGamma(shape + 1) * Math.pow(Math.random(), 1 / shape);
}
const d = shape - 1/3;
const c = 1 / Math.sqrt(9 * d);
while (true) {
const x = this.normalRandom();
const v = Math.pow(1 + c * x, 3);
if (v > 0 && Math.log(Math.random()) < 0.5 * x * x + d - d * v + d * Math.log(v)) {
return d * v;
}
}
}
/**
* Generate normal random variable
*/
normalRandom() {
// Box-Muller transform
const u1 = Math.random();
const u2 = Math.random();
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}
/**
* Check if user matches target audience
*/
matchesTargetAudience(userContext, targetAudience) {
// Check percentage allocation
if (Math.random() * 100 > targetAudience.percentage) {
return false;
}
// Check filters
for (const filter of targetAudience.filters || []) {
if (!this.evaluateFilter(userContext, filter)) {
return false;
}
}
// Check segments
if (targetAudience.segments?.length > 0) {
const userSegments = userContext.segments || [];
const hasSegment = targetAudience.segments.some(
segment => userSegments.includes(segment)
);
if (!hasSegment) {
return false;
}
}
return true;
}
/**
* Evaluate a single filter
*/
evaluateFilter(context, filter) {
const value = this.getNestedValue(context, filter.field);
const targetValue = filter.value;
switch (filter.operator) {
case 'equals':
return value === targetValue;
case 'not_equals':
return value !== targetValue;
case 'contains':
return value?.includes?.(targetValue);
case 'not_contains':
return !value?.includes?.(targetValue);
case 'greater_than':
return value > targetValue;
case 'less_than':
return value < targetValue;
case 'in':
return targetValue.includes(value);
case 'not_in':
return !targetValue.includes(value);
default:
return true;
}
}
/**
* Get nested value from object
*/
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => current?.[key], obj);
}
/**
* Update variant metrics
*/
async updateVariantMetrics(experimentId, variantId, metrics) {
const experiment = await Experiment.findOne({ experimentId });
if (!experiment) return;
experiment.updateMetrics(variantId, metrics);
await experiment.save();
// Update cache if exists
if (this.activeExperiments.has(experimentId)) {
this.activeExperiments.set(experimentId, experiment);
}
}
/**
* Analyze experiment results
*/
async analyzeExperiment(experimentId) {
const experiment = await Experiment.findOne({ experimentId });
if (!experiment) {
throw new Error('Experiment not found');
}
// Get detailed allocations data
const allocations = await Allocation.aggregate([
{ $match: { experimentId } },
{
$group: {
_id: '$variantId',
participants: { $sum: 1 },
conversions: {
$sum: { $cond: ['$convertedAt', 1, 0] }
},
revenue: {
$sum: { $ifNull: ['$conversionValue', 0] }
},
avgConversionTime: {
$avg: {
$cond: [
'$convertedAt',
{ $subtract: ['$convertedAt', '$exposedAt'] },
null
]
}
}
}
}
]);
// Run statistical analysis
const analysis = await statisticalAnalyzer.analyzeExperiment(
experiment,
allocations
);
return {
winner: analysis.winner,
summary: this.generateSummary(experiment, analysis),
recommendations: this.generateRecommendations(experiment, analysis),
statistics: analysis
};
}
/**
* Generate experiment summary
*/
generateSummary(experiment, analysis) {
const winner = experiment.variants.find(v => v.variantId === analysis.winner);
const control = experiment.variants.find(v => v.variantId === experiment.control);
if (!winner || !control) {
return 'Experiment completed without clear winner';
}
const improvement = ((winner.statistics.conversionRate - control.statistics.conversionRate) /
control.statistics.conversionRate * 100).toFixed(2);
return `Variant "${winner.name}" won with ${improvement}% improvement over control. ` +
`Statistical significance: ${(analysis.confidence * 100).toFixed(1)}%`;
}
/**
* Generate recommendations
*/
generateRecommendations(experiment, analysis) {
const recommendations = [];
// Winner recommendation
if (analysis.isSignificant) {
recommendations.push(
`Implement variant "${analysis.winner}" as it showed statistically significant improvement`
);
} else {
recommendations.push(
'No variant showed statistically significant improvement. Consider running experiment longer or with larger sample size'
);
}
// Sample size recommendation
if (analysis.sampleSizeAdequate) {
recommendations.push('Sample size was adequate for reliable results');
} else {
recommendations.push(
`Increase sample size to at least ${analysis.recommendedSampleSize} per variant for more reliable results`
);
}
// Practical significance
if (analysis.practicallySignificant) {
recommendations.push('The improvement is practically significant and worth implementing');
} else if (analysis.isSignificant) {
recommendations.push('While statistically significant, consider if the improvement justifies implementation costs');
}
return recommendations;
}
/**
* Check for early stopping
*/
async checkEarlyStopping(experimentId) {
const experiment = await Experiment.findOne({ experimentId });
if (!experiment || !experiment.settings.stopOnSignificance) {
return;
}
const analysis = await this.analyzeExperiment(experimentId);
if (analysis.isSignificant && analysis.sampleSizeAdequate) {
logger.info('Early stopping triggered', {
experimentId,
winner: analysis.winner,
confidence: analysis.confidence
});
await this.stopExperiment(experimentId, 'early_stopping');
}
}
/**
* Calculate required sample size
*/
calculateSampleSize(requirements) {
const {
statisticalPower = 0.8,
confidenceLevel = 0.95,
minimumDetectableEffect = 0.05
} = requirements;
// Z-scores
const zAlpha = this.getZScore(confidenceLevel);
const zBeta = this.getZScore(statisticalPower);
// Baseline conversion rate (assumed)
const p1 = 0.1; // 10% baseline
const p2 = p1 * (1 + minimumDetectableEffect);
const pBar = (p1 + p2) / 2;
// Sample size calculation
const n = Math.ceil(
2 * pBar * (1 - pBar) * Math.pow(zAlpha + zBeta, 2) /
Math.pow(p2 - p1, 2)
);
return n;
}
/**
* Get Z-score for confidence level
*/
getZScore(confidence) {
// Common z-scores
const zScores = {
0.80: 0.84,
0.85: 1.04,
0.90: 1.28,
0.95: 1.96,
0.99: 2.58
};
return zScores[confidence] || 1.96;
}
/**
* Validate experiment configuration
*/
validateExperiment(experiment) {
// Check variants
if (!experiment.variants || experiment.variants.length < 2) {
throw new Error('Experiment must have at least 2 variants');
}
// Check control
if (!experiment.variants.find(v => v.variantId === experiment.control)) {
throw new Error('Control variant not found');
}
// Check allocation
const totalAllocation = experiment.variants.reduce(
(sum, v) => sum + v.allocation.percentage, 0
);
if (Math.abs(totalAllocation - 100) > 0.01) {
throw new Error('Variant allocations must sum to 100%');
}
// Check target metric
if (!experiment.targetMetric?.name) {
throw new Error('Target metric must be specified');
}
}
/**
* Get experiment status
*/
async getExperimentStatus(experimentId) {
const experiment = await Experiment.findOne({ experimentId });
if (!experiment) {
throw new Error('Experiment not found');
}
const allocations = await Allocation.countDocuments({ experimentId });
const conversions = await Allocation.countDocuments({
experimentId,
convertedAt: { $exists: true }
});
const variantStats = await Promise.all(
experiment.variants.map(async (variant) => {
const variantAllocations = await Allocation.countDocuments({
experimentId,
variantId: variant.variantId
});
const variantConversions = await Allocation.countDocuments({
experimentId,
variantId: variant.variantId,
convertedAt: { $exists: true }
});
return {
variantId: variant.variantId,
name: variant.name,
participants: variantAllocations,
conversions: variantConversions,
conversionRate: variantAllocations > 0 ?
(variantConversions / variantAllocations * 100).toFixed(2) : 0
};
})
);
return {
experiment: {
id: experiment.experimentId,
name: experiment.name,
status: experiment.status,
type: experiment.type,
startDate: experiment.startDate,
endDate: experiment.endDate
},
overall: {
participants: allocations,
conversions,
conversionRate: allocations > 0 ?
(conversions / allocations * 100).toFixed(2) : 0
},
variants: variantStats,
progress: {
percentage: Math.min(100,
(allocations / experiment.requirements.minimumSampleSize) * 100
).toFixed(1),
daysRunning: experiment.startDate ?
Math.floor((Date.now() - experiment.startDate) / (1000 * 60 * 60 * 24)) : 0
}
};
}
}
// Export singleton instance
export const experimentManager = new ExperimentManager();

View File

@@ -0,0 +1,165 @@
import { CronJob } from 'cron';
import { Experiment } from '../models/Experiment.js';
import { ExperimentService } from './experimentService.js';
import { logger } from '../utils/logger.js';
export class ExperimentScheduler {
constructor(redisClient) {
this.redis = redisClient;
this.experimentService = new ExperimentService(redisClient);
this.jobs = new Map();
}
/**
* Start the scheduler
*/
async start() {
logger.info('Starting experiment scheduler');
// Check for scheduled experiments every minute
this.schedulerJob = new CronJob('* * * * *', async () => {
await this.checkScheduledExperiments();
});
// Check for experiments to complete every hour
this.completionJob = new CronJob('0 * * * *', async () => {
await this.checkExperimentsToComplete();
});
// Check for early stopping every 30 minutes
this.earlyStoppingJob = new CronJob('*/30 * * * *', async () => {
await this.checkEarlyStopping();
});
this.schedulerJob.start();
this.completionJob.start();
this.earlyStoppingJob.start();
// Initial check
await this.checkScheduledExperiments();
}
/**
* Stop the scheduler
*/
async stop() {
logger.info('Stopping experiment scheduler');
if (this.schedulerJob) this.schedulerJob.stop();
if (this.completionJob) this.completionJob.stop();
if (this.earlyStoppingJob) this.earlyStoppingJob.stop();
// Stop all experiment-specific jobs
for (const [experimentId, job] of this.jobs) {
job.stop();
this.jobs.delete(experimentId);
}
}
/**
* Check for experiments that should be started
*/
async checkScheduledExperiments() {
try {
const experiments = await Experiment.findScheduled();
for (const experiment of experiments) {
logger.info(`Starting scheduled experiment ${experiment.experimentId}`);
try {
await this.experimentService.startExperiment(
experiment.experimentId,
experiment.accountId
);
} catch (error) {
logger.error(`Failed to start scheduled experiment ${experiment.experimentId}:`, error);
}
}
} catch (error) {
logger.error('Error checking scheduled experiments:', error);
}
}
/**
* Check for experiments that should be completed
*/
async checkExperimentsToComplete() {
try {
const experiments = await Experiment.find({
status: 'running',
scheduledEnd: { $lte: new Date() }
});
for (const experiment of experiments) {
logger.info(`Completing scheduled experiment ${experiment.experimentId}`);
try {
await this.experimentService.completeExperiment(
experiment.experimentId,
experiment.accountId
);
} catch (error) {
logger.error(`Failed to complete experiment ${experiment.experimentId}:`, error);
}
}
} catch (error) {
logger.error('Error checking experiments to complete:', error);
}
}
/**
* Check for early stopping
*/
async checkEarlyStopping() {
try {
const experiments = await Experiment.find({
status: 'running',
'settings.stopOnSignificance': true
});
for (const experiment of experiments) {
try {
await this.experimentService.checkEarlyStopping(experiment.experimentId);
} catch (error) {
logger.error(`Error checking early stopping for ${experiment.experimentId}:`, error);
}
}
} catch (error) {
logger.error('Error checking early stopping:', error);
}
}
/**
* Schedule a specific job for an experiment
*/
scheduleExperimentJob(experimentId, cronPattern, callback) {
// Remove existing job if any
if (this.jobs.has(experimentId)) {
this.jobs.get(experimentId).stop();
}
const job = new CronJob(cronPattern, async () => {
try {
await callback();
} catch (error) {
logger.error(`Error in scheduled job for experiment ${experimentId}:`, error);
}
});
job.start();
this.jobs.set(experimentId, job);
logger.info(`Scheduled job for experiment ${experimentId} with pattern ${cronPattern}`);
}
/**
* Cancel a scheduled job
*/
cancelExperimentJob(experimentId) {
if (this.jobs.has(experimentId)) {
this.jobs.get(experimentId).stop();
this.jobs.delete(experimentId);
logger.info(`Cancelled scheduled job for experiment ${experimentId}`);
}
}
}

View File

@@ -0,0 +1,343 @@
import { Experiment } from '../models/Experiment.js';
import { Allocation } from '../models/Allocation.js';
import { AllocationManager } from '../algorithms/index.js';
import { publishEvent } from './messaging.js';
import { logger } from '../utils/logger.js';
import { StatisticalAnalyzer } from './statisticalAnalyzer.js';
export class ExperimentService {
constructor(redisClient) {
this.redis = redisClient;
this.allocationManager = new AllocationManager(redisClient);
this.statisticalAnalyzer = new StatisticalAnalyzer();
}
/**
* Start an experiment
*/
async startExperiment(experimentId, accountId) {
const experiment = await Experiment.findOne({ experimentId, accountId });
if (!experiment) {
throw new Error('Experiment not found');
}
if (!['draft', 'scheduled', 'paused'].includes(experiment.status)) {
throw new Error(`Cannot start experiment in ${experiment.status} status`);
}
// Validate experiment setup
this.validateExperimentSetup(experiment);
// Update status
experiment.status = 'running';
experiment.startDate = new Date();
await experiment.save();
// Publish event
await publishEvent('experiment.started', {
experimentId,
accountId,
startDate: experiment.startDate
});
logger.info(`Started experiment ${experimentId}`);
return experiment;
}
/**
* Pause an experiment
*/
async pauseExperiment(experimentId, accountId) {
const experiment = await Experiment.findOne({ experimentId, accountId });
if (!experiment) {
throw new Error('Experiment not found');
}
if (experiment.status !== 'running') {
throw new Error(`Cannot pause experiment in ${experiment.status} status`);
}
experiment.status = 'paused';
await experiment.save();
// Publish event
await publishEvent('experiment.paused', {
experimentId,
accountId,
pausedAt: new Date()
});
logger.info(`Paused experiment ${experimentId}`);
return experiment;
}
/**
* Resume an experiment
*/
async resumeExperiment(experimentId, accountId) {
const experiment = await Experiment.findOne({ experimentId, accountId });
if (!experiment) {
throw new Error('Experiment not found');
}
if (experiment.status !== 'paused') {
throw new Error(`Cannot resume experiment in ${experiment.status} status`);
}
experiment.status = 'running';
await experiment.save();
// Publish event
await publishEvent('experiment.resumed', {
experimentId,
accountId,
resumedAt: new Date()
});
logger.info(`Resumed experiment ${experimentId}`);
return experiment;
}
/**
* Complete an experiment
*/
async completeExperiment(experimentId, accountId) {
const experiment = await Experiment.findOne({ experimentId, accountId });
if (!experiment) {
throw new Error('Experiment not found');
}
if (!['running', 'paused'].includes(experiment.status)) {
throw new Error(`Cannot complete experiment in ${experiment.status} status`);
}
// Get final statistics
const stats = await Allocation.getExperimentStats(experimentId);
// Perform statistical analysis
const analysis = await this.statisticalAnalyzer.analyze(experiment, stats);
// Determine winner
const winner = this.determineWinner(experiment, analysis);
// Update experiment
experiment.status = 'completed';
experiment.endDate = new Date();
experiment.results = {
winner: winner?.variantId,
completedAt: new Date(),
summary: this.generateSummary(experiment, analysis),
recommendations: this.generateRecommendations(experiment, analysis),
statisticalAnalysis: analysis
};
await experiment.save();
// Publish event
await publishEvent('experiment.completed', {
experimentId,
accountId,
winner: winner?.variantId,
completedAt: experiment.results.completedAt
});
logger.info(`Completed experiment ${experimentId}`);
return experiment;
}
/**
* Validate experiment setup
*/
validateExperimentSetup(experiment) {
// Check minimum sample size
const minSampleSize = experiment.requirements?.minimumSampleSize || 100;
if (experiment.variants.some(v => v.metrics.participants < minSampleSize)) {
logger.warn(`Some variants have not reached minimum sample size of ${minSampleSize}`);
}
// Check allocation percentages for random allocation
if (experiment.allocation.method === 'random') {
const total = experiment.variants.reduce(
(sum, v) => sum + (v.allocation?.percentage || 0),
0
);
if (Math.abs(total - 100) > 0.01) {
throw new Error('Variant allocation percentages must sum to 100');
}
}
// Ensure control variant exists
const controlVariant = experiment.variants.find(
v => v.variantId === experiment.control
);
if (!controlVariant) {
throw new Error('Control variant not found');
}
}
/**
* Determine winner based on statistical analysis
*/
determineWinner(experiment, analysis) {
const { targetMetric } = experiment;
const significanceLevel = experiment.requirements?.confidenceLevel || 0.95;
// Find variants that significantly outperform control
const significantWinners = [];
for (const [variantId, variantAnalysis] of Object.entries(analysis.variants)) {
if (variantId === experiment.control) continue;
const comparison = variantAnalysis.comparisonToControl;
if (!comparison) continue;
// Check if significantly better than control
if (comparison.pValue < (1 - significanceLevel)) {
const improvementDirection = targetMetric.goalDirection === 'increase'
? comparison.relativeImprovement > 0
: comparison.relativeImprovement < 0;
if (improvementDirection) {
significantWinners.push({
variantId,
improvement: Math.abs(comparison.relativeImprovement),
pValue: comparison.pValue
});
}
}
}
// Return the best performing winner
if (significantWinners.length > 0) {
significantWinners.sort((a, b) => b.improvement - a.improvement);
return experiment.variants.find(
v => v.variantId === significantWinners[0].variantId
);
}
return null;
}
/**
* Generate experiment summary
*/
generateSummary(experiment, analysis) {
const winner = this.determineWinner(experiment, analysis);
const duration = experiment.endDate - experiment.startDate;
const durationDays = Math.ceil(duration / (1000 * 60 * 60 * 24));
let summary = `Experiment "${experiment.name}" ran for ${durationDays} days. `;
if (winner) {
const winnerAnalysis = analysis.variants[winner.variantId];
const improvement = winnerAnalysis.comparisonToControl.relativeImprovement;
summary += `Variant "${winner.name}" won with a ${(improvement * 100).toFixed(1)}% improvement over control. `;
} else {
summary += `No variant showed statistically significant improvement over control. `;
}
const totalParticipants = experiment.variants.reduce(
(sum, v) => sum + v.metrics.participants,
0
);
summary += `Total participants: ${totalParticipants}.`;
return summary;
}
/**
* Generate recommendations based on results
*/
generateRecommendations(experiment, analysis) {
const recommendations = [];
const winner = this.determineWinner(experiment, analysis);
if (winner) {
recommendations.push(
`Implement variant "${winner.name}" as it showed significant improvement.`
);
// Check if sample size was sufficient
const minSampleSize = experiment.requirements?.minimumSampleSize || 100;
if (winner.metrics.participants < minSampleSize * 2) {
recommendations.push(
'Consider running the experiment longer to gather more data for higher confidence.'
);
}
} else {
recommendations.push(
'No clear winner emerged. Consider testing more dramatic variations.'
);
// Check if experiment ran long enough
const avgParticipants = experiment.variants.reduce(
(sum, v) => sum + v.metrics.participants, 0
) / experiment.variants.length;
if (avgParticipants < experiment.requirements?.minimumSampleSize) {
recommendations.push(
'Experiment did not reach minimum sample size. Run longer for conclusive results.'
);
}
}
// Check for high variance
const highVarianceVariants = Object.entries(analysis.variants)
.filter(([_, v]) => v.statistics?.coefficientOfVariation > 1)
.map(([id]) => id);
if (highVarianceVariants.length > 0) {
recommendations.push(
'High variance detected in results. Consider segmenting users for more targeted testing.'
);
}
return recommendations;
}
/**
* Check if experiment should stop early
*/
async checkEarlyStopping(experimentId) {
const experiment = await Experiment.findById(experimentId);
if (!experiment || experiment.status !== 'running') {
return false;
}
if (!experiment.settings?.stopOnSignificance) {
return false;
}
const stats = await Allocation.getExperimentStats(experimentId);
const analysis = await this.statisticalAnalyzer.analyze(experiment, stats);
// Check if we have a clear winner with high confidence
const winner = this.determineWinner(experiment, analysis);
if (winner) {
const winnerAnalysis = analysis.variants[winner.variantId];
const pValue = winnerAnalysis.comparisonToControl?.pValue || 1;
// Stop if p-value is very low (high confidence)
if (pValue < 0.001) {
logger.info(`Early stopping triggered for experiment ${experimentId}`);
await this.completeExperiment(experimentId, experiment.accountId);
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,126 @@
import amqp from 'amqplib';
import { config } from '../config/index.js';
import { logger } from '../utils/logger.js';
let connection = null;
let channel = null;
/**
* Connect to RabbitMQ
*/
export const connectRabbitMQ = async () => {
try {
connection = await amqp.connect(config.rabbitmq.url);
channel = await connection.createChannel();
// Create exchange
await channel.assertExchange(config.rabbitmq.exchange, 'topic', {
durable: true
});
// Create queues
for (const [name, queueName] of Object.entries(config.rabbitmq.queues)) {
await channel.assertQueue(queueName, { durable: true });
}
logger.info('Connected to RabbitMQ');
// Handle connection events
connection.on('error', (err) => {
logger.error('RabbitMQ connection error:', err);
});
connection.on('close', () => {
logger.error('RabbitMQ connection closed, reconnecting...');
setTimeout(connectRabbitMQ, 5000);
});
return channel;
} catch (error) {
logger.error('Failed to connect to RabbitMQ:', error);
setTimeout(connectRabbitMQ, 5000);
throw error;
}
};
/**
* Publish event to RabbitMQ
*/
export const publishEvent = async (eventType, data) => {
try {
if (!channel) {
await connectRabbitMQ();
}
const message = {
eventType,
data,
timestamp: new Date().toISOString(),
service: 'ab-testing'
};
const routingKey = `ab-testing.${eventType}`;
channel.publish(
config.rabbitmq.exchange,
routingKey,
Buffer.from(JSON.stringify(message)),
{ persistent: true }
);
logger.debug(`Published event: ${eventType}`, { data });
} catch (error) {
logger.error('Failed to publish event:', error);
}
};
/**
* Subscribe to events
*/
export const subscribeToEvents = async (patterns, handler) => {
try {
if (!channel) {
await connectRabbitMQ();
}
// Create exclusive queue for this consumer
const { queue } = await channel.assertQueue('', { exclusive: true });
// Bind patterns
for (const pattern of patterns) {
await channel.bindQueue(queue, config.rabbitmq.exchange, pattern);
}
// Consume messages
channel.consume(queue, async (msg) => {
if (!msg) return;
try {
const message = JSON.parse(msg.content.toString());
await handler(message);
channel.ack(msg);
} catch (error) {
logger.error('Error processing message:', error);
channel.nack(msg, false, false); // Don't requeue
}
});
logger.info(`Subscribed to patterns: ${patterns.join(', ')}`);
} catch (error) {
logger.error('Failed to subscribe to events:', error);
throw error;
}
};
/**
* Close RabbitMQ connection
*/
export const closeConnection = async () => {
try {
if (channel) await channel.close();
if (connection) await connection.close();
logger.info('RabbitMQ connection closed');
} catch (error) {
logger.error('Error closing RabbitMQ connection:', error);
}
};

View File

@@ -0,0 +1,619 @@
import jStat from 'jstat';
import * as stats from 'simple-statistics';
import { logger } from '../utils/logger.js';
export class StatisticalAnalyzer {
constructor() {
this.jstat = jStat.jStat;
}
/**
* Analyze experiment results
*/
async analyzeExperiment(experiment, aggregatedData) {
// Convert aggregated data to variant stats format
const variantStats = aggregatedData.map(data => ({
variantId: data._id,
participants: data.participants,
conversions: data.conversions,
revenue: data.revenue,
avgConversionTime: data.avgConversionTime
}));
return this.analyze(experiment, variantStats);
}
/**
* Analyze experiment results (internal)
*/
async analyze(experiment, variantStats) {
const analysis = {
summary: this.calculateSummaryStatistics(variantStats),
variants: {},
comparisons: {},
powerAnalysis: null,
recommendations: [],
winner: null,
isSignificant: false,
confidence: 0,
sampleSizeAdequate: false,
practicallySignificant: false,
recommendedSampleSize: 0
};
// Find control variant stats
const controlStats = variantStats.find(v => v.variantId === experiment.control);
if (!controlStats) {
throw new Error('Control variant statistics not found');
}
// Analyze each variant
for (const variantStat of variantStats) {
analysis.variants[variantStat.variantId] = await this.analyzeVariant(
variantStat,
controlStats,
experiment
);
}
// Perform pairwise comparisons
if (experiment.type === 'multivariate' || variantStats.length > 2) {
analysis.comparisons = this.performPairwiseComparisons(variantStats, experiment);
}
// Power analysis
analysis.powerAnalysis = this.performPowerAnalysis(experiment, variantStats);
// Generate recommendations
analysis.recommendations = this.generateStatisticalRecommendations(analysis, experiment);
// Determine winner and significance
const winnerResult = this.determineWinner(analysis, experiment);
analysis.winner = winnerResult.winner;
analysis.isSignificant = winnerResult.isSignificant;
analysis.confidence = winnerResult.confidence;
// Check sample size adequacy
analysis.sampleSizeAdequate = this.checkSampleSizeAdequacy(variantStats, experiment);
analysis.recommendedSampleSize = this.calculateRecommendedSampleSize(experiment, variantStats);
// Check practical significance
if (analysis.winner && analysis.variants[analysis.winner]) {
analysis.practicallySignificant = this.checkPracticalSignificance(
analysis.variants[analysis.winner],
experiment
);
}
return analysis;
}
/**
* Calculate summary statistics across all variants
*/
calculateSummaryStatistics(variantStats) {
const totalParticipants = variantStats.reduce((sum, v) => sum + v.participants, 0);
const totalConversions = variantStats.reduce((sum, v) => sum + v.conversions, 0);
const totalRevenue = variantStats.reduce((sum, v) => sum + v.revenue, 0);
return {
totalParticipants,
totalConversions,
overallConversionRate: totalParticipants > 0 ? totalConversions / totalParticipants : 0,
totalRevenue,
averageRevenuePerUser: totalParticipants > 0 ? totalRevenue / totalParticipants : 0,
variantCount: variantStats.length
};
}
/**
* Analyze individual variant
*/
async analyzeVariant(variantStat, controlStats, experiment) {
const { participants, conversions, revenue } = variantStat;
// Basic metrics
const conversionRate = participants > 0 ? conversions / participants : 0;
const revenuePerUser = participants > 0 ? revenue / participants : 0;
// Confidence intervals
const conversionCI = this.calculateBinomialCI(conversions, participants);
// Statistical tests vs control
let comparisonToControl = null;
if (variantStat.variantId !== experiment.control) {
comparisonToControl = this.compareVariants(variantStat, controlStats, experiment);
}
// Calculate additional statistics
const statistics = {
mean: conversionRate,
variance: this.calculateBinomialVariance(conversionRate, participants),
standardError: this.calculateStandardError(conversionRate, participants),
coefficientOfVariation: conversionRate > 0
? Math.sqrt(this.calculateBinomialVariance(conversionRate, participants)) / conversionRate
: 0
};
return {
participants,
conversions,
conversionRate,
conversionCI,
revenue,
revenuePerUser,
statistics,
comparisonToControl,
isControl: variantStat.variantId === experiment.control
};
}
/**
* Compare two variants
*/
compareVariants(variantA, variantB, experiment) {
const {
pValue,
zScore
} = this.performZTest(
variantA.conversions,
variantA.participants,
variantB.conversions,
variantB.participants
);
const conversionRateA = variantA.participants > 0 ? variantA.conversions / variantA.participants : 0;
const conversionRateB = variantB.participants > 0 ? variantB.conversions / variantB.participants : 0;
const absoluteImprovement = conversionRateA - conversionRateB;
const relativeImprovement = conversionRateB > 0
? (conversionRateA - conversionRateB) / conversionRateB
: 0;
// Calculate confidence interval for the difference
const differenceCI = this.calculateDifferenceCI(
variantA.conversions,
variantA.participants,
variantB.conversions,
variantB.participants
);
// Bayesian analysis
const bayesianResult = this.performBayesianAnalysis(
variantA.conversions,
variantA.participants,
variantB.conversions,
variantB.participants
);
return {
pValue,
zScore,
absoluteImprovement,
relativeImprovement,
differenceCI,
significant: pValue < (1 - (experiment.requirements?.confidenceLevel || 0.95)),
bayesian: bayesianResult
};
}
/**
* Perform Z-test for proportions
*/
performZTest(successesA, trialsA, successesB, trialsB) {
if (trialsA === 0 || trialsB === 0) {
return { pValue: 1, zScore: 0 };
}
const pA = successesA / trialsA;
const pB = successesB / trialsB;
// Pooled proportion
const pPooled = (successesA + successesB) / (trialsA + trialsB);
// Standard error
const se = Math.sqrt(pPooled * (1 - pPooled) * (1/trialsA + 1/trialsB));
if (se === 0) {
return { pValue: 1, zScore: 0 };
}
// Z-score
const zScore = (pA - pB) / se;
// Two-tailed p-value
const pValue = 2 * (1 - this.jstat.normal.cdf(Math.abs(zScore), 0, 1));
return { pValue, zScore };
}
/**
* Calculate binomial confidence interval (Wilson score interval)
*/
calculateBinomialCI(successes, trials, confidence = 0.95) {
if (trials === 0) {
return { lower: 0, upper: 0 };
}
const z = this.jstat.normal.inv(1 - (1 - confidence) / 2, 0, 1);
const p = successes / trials;
const n = trials;
const denominator = 1 + z * z / n;
const centre = (p + z * z / (2 * n)) / denominator;
const spread = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denominator;
return {
lower: Math.max(0, centre - spread),
upper: Math.min(1, centre + spread)
};
}
/**
* Calculate confidence interval for difference in proportions
*/
calculateDifferenceCI(successesA, trialsA, successesB, trialsB, confidence = 0.95) {
if (trialsA === 0 || trialsB === 0) {
return { lower: 0, upper: 0 };
}
const pA = successesA / trialsA;
const pB = successesB / trialsB;
const difference = pA - pB;
const seA = Math.sqrt(pA * (1 - pA) / trialsA);
const seB = Math.sqrt(pB * (1 - pB) / trialsB);
const seDiff = Math.sqrt(seA * seA + seB * seB);
const z = this.jstat.normal.inv(1 - (1 - confidence) / 2, 0, 1);
return {
lower: difference - z * seDiff,
upper: difference + z * seDiff
};
}
/**
* Perform Bayesian analysis
*/
performBayesianAnalysis(successesA, trialsA, successesB, trialsB) {
// Using Beta conjugate priors
const alphaA = successesA + 1;
const betaA = trialsA - successesA + 1;
const alphaB = successesB + 1;
const betaB = trialsB - successesB + 1;
// Monte Carlo simulation for probability A > B
const samples = 10000;
let countAGreaterThanB = 0;
for (let i = 0; i < samples; i++) {
const sampleA = this.sampleBeta(alphaA, betaA);
const sampleB = this.sampleBeta(alphaB, betaB);
if (sampleA > sampleB) {
countAGreaterThanB++;
}
}
const probabilityABetterThanB = countAGreaterThanB / samples;
// Expected improvement
let totalImprovement = 0;
for (let i = 0; i < samples; i++) {
const sampleA = this.sampleBeta(alphaA, betaA);
const sampleB = this.sampleBeta(alphaB, betaB);
if (sampleA > sampleB) {
totalImprovement += (sampleA - sampleB) / sampleB;
}
}
const expectedImprovement = totalImprovement / countAGreaterThanB;
return {
probabilityABetterThanB,
expectedImprovement: isNaN(expectedImprovement) ? 0 : expectedImprovement,
posteriorMeanA: alphaA / (alphaA + betaA),
posteriorMeanB: alphaB / (alphaB + betaB)
};
}
/**
* Sample from Beta distribution
*/
sampleBeta(alpha, beta) {
// Using the relationship between Gamma and Beta distributions
const x = this.jstat.gamma.sample(alpha, 1);
const y = this.jstat.gamma.sample(beta, 1);
return x / (x + y);
}
/**
* Perform pairwise comparisons for multiple variants
*/
performPairwiseComparisons(variantStats, experiment) {
const comparisons = {};
const variants = variantStats.map(v => v.variantId);
for (let i = 0; i < variants.length; i++) {
for (let j = i + 1; j < variants.length; j++) {
const variantA = variantStats[i];
const variantB = variantStats[j];
const key = `${variantA.variantId}_vs_${variantB.variantId}`;
comparisons[key] = this.compareVariants(variantA, variantB, experiment);
}
}
// Apply multiple testing correction if specified
if (experiment.settings?.multipleTestingCorrection !== 'none') {
this.applyMultipleTestingCorrection(
comparisons,
experiment.settings.multipleTestingCorrection
);
}
return comparisons;
}
/**
* Apply multiple testing correction
*/
applyMultipleTestingCorrection(comparisons, method) {
const pValues = Object.values(comparisons).map(c => c.pValue);
const alpha = 0.05; // Significance level
switch (method) {
case 'bonferroni':
const bonferroniAlpha = alpha / pValues.length;
for (const comparison of Object.values(comparisons)) {
comparison.adjustedPValue = Math.min(comparison.pValue * pValues.length, 1);
comparison.significant = comparison.pValue < bonferroniAlpha;
}
break;
case 'benjamini-hochberg':
// Sort p-values
const sorted = pValues.slice().sort((a, b) => a - b);
const n = pValues.length;
// Find the largest i such that P(i) <= (i/n) * alpha
let threshold = 0;
for (let i = n - 1; i >= 0; i--) {
if (sorted[i] <= ((i + 1) / n) * alpha) {
threshold = sorted[i];
break;
}
}
for (const comparison of Object.values(comparisons)) {
comparison.adjustedPValue = comparison.pValue;
comparison.significant = comparison.pValue <= threshold;
}
break;
}
}
/**
* Perform power analysis
*/
performPowerAnalysis(experiment, variantStats) {
const controlStats = variantStats.find(v => v.variantId === experiment.control);
if (!controlStats || controlStats.participants === 0) {
return null;
}
const baselineRate = controlStats.conversions / controlStats.participants;
const mde = experiment.requirements?.minimumDetectableEffect || 0.05;
const alpha = 1 - (experiment.requirements?.confidenceLevel || 0.95);
const desiredPower = experiment.requirements?.statisticalPower || 0.8;
// Calculate required sample size per variant
const requiredSampleSize = this.calculateSampleSize(
baselineRate,
baselineRate * (1 + mde),
alpha,
desiredPower
);
// Calculate achieved power for each variant
const achievedPower = {};
for (const variantStat of variantStats) {
if (variantStat.variantId !== experiment.control) {
achievedPower[variantStat.variantId] = this.calculatePower(
baselineRate,
baselineRate * (1 + mde),
alpha,
variantStat.participants,
controlStats.participants
);
}
}
return {
requiredSampleSizePerVariant: requiredSampleSize,
minimumDetectableEffect: mde,
baselineConversionRate: baselineRate,
achievedPower,
sufficientPower: Object.values(achievedPower).every(p => p >= desiredPower)
};
}
/**
* Calculate required sample size
*/
calculateSampleSize(p1, p2, alpha, power) {
const z_alpha = this.jstat.normal.inv(1 - alpha / 2, 0, 1);
const z_beta = this.jstat.normal.inv(power, 0, 1);
const p = (p1 + p2) / 2;
const q = 1 - p;
const n = Math.pow(z_alpha + z_beta, 2) * (p1 * (1 - p1) + p2 * (1 - p2)) / Math.pow(p2 - p1, 2);
return Math.ceil(n);
}
/**
* Calculate statistical power
*/
calculatePower(p1, p2, alpha, n1, n2) {
const z_alpha = this.jstat.normal.inv(1 - alpha / 2, 0, 1);
const delta = Math.abs(p2 - p1);
const pooledP = (p1 + p2) / 2;
const se = Math.sqrt(pooledP * (1 - pooledP) * (1/n1 + 1/n2));
if (se === 0) return 0;
const z = delta / se - z_alpha;
return this.jstat.normal.cdf(z, 0, 1);
}
/**
* Calculate binomial variance
*/
calculateBinomialVariance(p, n) {
return p * (1 - p) / n;
}
/**
* Calculate standard error
*/
calculateStandardError(p, n) {
return Math.sqrt(this.calculateBinomialVariance(p, n));
}
/**
* Generate statistical recommendations
*/
generateStatisticalRecommendations(analysis, experiment) {
const recommendations = [];
// Check sample size
if (analysis.powerAnalysis && !analysis.powerAnalysis.sufficientPower) {
recommendations.push({
type: 'sample_size',
priority: 'high',
message: 'Experiment has not reached sufficient statistical power. Continue running to gather more data.'
});
}
// Check for high variance
const highVarianceVariants = Object.entries(analysis.variants)
.filter(([_, v]) => v.statistics.coefficientOfVariation > 1)
.map(([id]) => id);
if (highVarianceVariants.length > 0) {
recommendations.push({
type: 'variance',
priority: 'medium',
message: 'High variance detected. Consider segmentation or running the experiment longer.'
});
}
// Check for practical significance
const significantButSmall = Object.entries(analysis.variants)
.filter(([id, v]) =>
id !== experiment.control &&
v.comparisonToControl?.significant &&
Math.abs(v.comparisonToControl.relativeImprovement) < 0.05
);
if (significantButSmall.length > 0) {
recommendations.push({
type: 'practical_significance',
priority: 'low',
message: 'Statistically significant but small effect size detected. Consider if the improvement is practically meaningful.'
});
}
return recommendations;
}
/**
* Determine the winning variant
*/
determineWinner(analysis, experiment) {
let winner = experiment.control;
let maxImprovement = 0;
let isSignificant = false;
let confidence = 0;
const significanceLevel = 1 - (experiment.requirements?.confidenceLevel || 0.95);
// Check each variant against control
for (const [variantId, variantAnalysis] of Object.entries(analysis.variants)) {
if (variantAnalysis.isControl) continue;
const comparison = variantAnalysis.comparisonToControl;
if (!comparison) continue;
// Check if statistically significant
if (comparison.significant) {
// Check if this is the best improvement so far
if (comparison.relativeImprovement > maxImprovement) {
winner = variantId;
maxImprovement = comparison.relativeImprovement;
isSignificant = true;
confidence = 1 - comparison.pValue;
}
}
}
// If no significant improvement, pick best performer
if (!isSignificant) {
let bestRate = 0;
for (const [variantId, variantAnalysis] of Object.entries(analysis.variants)) {
if (variantAnalysis.conversionRate > bestRate) {
bestRate = variantAnalysis.conversionRate;
winner = variantId;
confidence = variantAnalysis.comparisonToControl
? (1 - variantAnalysis.comparisonToControl.pValue)
: 0;
}
}
}
return {
winner,
isSignificant,
confidence,
maxImprovement
};
}
/**
* Check if sample size is adequate
*/
checkSampleSizeAdequacy(variantStats, experiment) {
const minSampleSize = experiment.requirements?.minimumSampleSize || 100;
for (const stats of variantStats) {
if (stats.participants < minSampleSize) {
return false;
}
}
return true;
}
/**
* Calculate recommended sample size
*/
calculateRecommendedSampleSize(experiment, variantStats) {
const powerAnalysis = this.performPowerAnalysis(experiment, variantStats);
return powerAnalysis?.requiredSampleSizePerVariant || 100;
}
/**
* Check practical significance
*/
checkPracticalSignificance(variantAnalysis, experiment) {
if (!variantAnalysis || !variantAnalysis.comparisonToControl) return false;
const minEffect = experiment.requirements?.minimumDetectableEffect || 0.05;
return Math.abs(variantAnalysis.comparisonToControl.relativeImprovement) >= minEffect;
}
}
// Export singleton instance
export const statisticalAnalyzer = new StatisticalAnalyzer();

View File

@@ -0,0 +1,106 @@
import jwt from 'jsonwebtoken';
import { config } from '../config/index.js';
import { logger } from './logger.js';
/**
* JWT authentication middleware
*/
export const authMiddleware = (req, res, next) => {
try {
// Get token from header
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'No authorization header' });
}
const token = authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: authHeader;
// Verify token
const decoded = jwt.verify(token, config.jwt.secret);
// Attach auth info to request
req.auth = {
accountId: decoded.accountId,
userId: decoded.userId,
role: decoded.role || 'user',
permissions: decoded.permissions || []
};
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
logger.error('Auth middleware error:', error);
return res.status(500).json({ error: 'Authentication error' });
}
};
/**
* Generate JWT token
*/
export const generateToken = (payload) => {
return jwt.sign(payload, config.jwt.secret, {
expiresIn: config.jwt.expiresIn
});
};
/**
* Role-based access control middleware
*/
export const requireRole = (roles) => {
return (req, res, next) => {
if (!req.auth) {
return res.status(401).json({ error: 'Not authenticated' });
}
const userRole = req.auth.role;
const allowedRoles = Array.isArray(roles) ? roles : [roles];
if (!allowedRoles.includes(userRole)) {
return res.status(403).json({
error: 'Insufficient permissions',
required: allowedRoles,
current: userRole
});
}
next();
};
};
/**
* Permission-based access control
*/
export const requirePermission = (permissions) => {
return (req, res, next) => {
if (!req.auth) {
return res.status(401).json({ error: 'Not authenticated' });
}
const userPermissions = req.auth.permissions || [];
const requiredPermissions = Array.isArray(permissions) ? permissions : [permissions];
const hasPermission = requiredPermissions.every(
perm => userPermissions.includes(perm)
);
if (!hasPermission) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredPermissions,
current: userPermissions
});
}
next();
};
};

View File

@@ -0,0 +1,49 @@
import winston from 'winston';
import { config } from '../config/index.js';
const { combine, timestamp, json, printf, colorize } = winston.format;
// Custom format for console output
const consoleFormat = printf(({ level, message, timestamp, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
return `${timestamp} [${level}]: ${message}${metaStr}`;
});
// Create logger
export const logger = winston.createLogger({
level: config.logging.level,
format: combine(
timestamp(),
json()
),
transports: [
// Console transport
new winston.transports.Console({
format: combine(
colorize(),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
consoleFormat
)
}),
// File transport
new winston.transports.File({
filename: config.logging.file,
format: combine(
timestamp(),
json()
)
})
]
});
// Add error file transport in production
if (config.env === 'production') {
logger.add(new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
format: combine(
timestamp(),
json()
)
}));
}

View File

@@ -0,0 +1,131 @@
import promClient from 'prom-client';
import { config } from '../config/index.js';
import { logger } from './logger.js';
// Create a Registry
const register = new promClient.Registry();
// Add default metrics
promClient.collectDefaultMetrics({ register });
// Custom metrics
const metrics = {
// Experiments
experimentsTotal: new promClient.Counter({
name: `${config.metrics.prefix}experiments_total`,
help: 'Total number of experiments created',
labelNames: ['status', 'type']
}),
activeExperiments: new promClient.Gauge({
name: `${config.metrics.prefix}active_experiments`,
help: 'Number of currently active experiments'
}),
// Allocations
allocationsTotal: new promClient.Counter({
name: `${config.metrics.prefix}allocations_total`,
help: 'Total number of allocations',
labelNames: ['experiment_id', 'variant_id', 'method']
}),
allocationDuration: new promClient.Histogram({
name: `${config.metrics.prefix}allocation_duration_seconds`,
help: 'Time taken to allocate user to variant',
labelNames: ['experiment_id'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1]
}),
// Conversions
conversionsTotal: new promClient.Counter({
name: `${config.metrics.prefix}conversions_total`,
help: 'Total number of conversions',
labelNames: ['experiment_id', 'variant_id']
}),
conversionValue: new promClient.Summary({
name: `${config.metrics.prefix}conversion_value`,
help: 'Conversion values',
labelNames: ['experiment_id', 'variant_id'],
percentiles: [0.5, 0.9, 0.95, 0.99]
}),
// Statistical Analysis
analysisRequests: new promClient.Counter({
name: `${config.metrics.prefix}analysis_requests_total`,
help: 'Total number of analysis requests',
labelNames: ['type']
}),
analysisDuration: new promClient.Histogram({
name: `${config.metrics.prefix}analysis_duration_seconds`,
help: 'Time taken for statistical analysis',
labelNames: ['type'],
buckets: [0.1, 0.5, 1, 2, 5, 10]
}),
// Algorithm performance
algorithmUpdates: new promClient.Counter({
name: `${config.metrics.prefix}algorithm_updates_total`,
help: 'Total number of algorithm updates',
labelNames: ['algorithm', 'experiment_id']
}),
// API metrics
apiRequests: new promClient.Counter({
name: `${config.metrics.prefix}api_requests_total`,
help: 'Total number of API requests',
labelNames: ['method', 'route', 'status']
}),
apiRequestDuration: new promClient.Histogram({
name: `${config.metrics.prefix}api_request_duration_seconds`,
help: 'API request duration',
labelNames: ['method', 'route'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5]
})
};
// Register all metrics
Object.values(metrics).forEach(metric => register.registerMetric(metric));
// Middleware to track API metrics
export const metricsMiddleware = (req, res, next) => {
const start = Date.now();
// Track response
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
const route = req.route?.path || 'unknown';
const method = req.method;
const status = res.statusCode;
metrics.apiRequests.inc({ method, route, status });
metrics.apiRequestDuration.observe({ method, route }, duration);
});
next();
};
// Start metrics server
export const startMetricsServer = (port) => {
const express = require('express');
const app = express();
app.get('/metrics', async (req, res) => {
try {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
} catch (error) {
logger.error('Error serving metrics:', error);
res.status(500).end();
}
});
app.listen(port, () => {
logger.info(`Metrics server listening on port ${port}`);
});
};
// Export metrics for use in services
export { metrics };

View File

@@ -0,0 +1,84 @@
import Joi from 'joi';
import { logger } from './logger.js';
/**
* Express middleware for request validation
*/
export const validateRequest = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false,
stripUnknown: true
});
if (error) {
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
logger.warn('Validation failed:', { errors, body: req.body });
return res.status(400).json({
error: 'Validation failed',
errors
});
}
// Replace request body with validated and cleaned data
req.body = value;
next();
};
};
/**
* Validate query parameters
*/
export const validateQuery = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.query, {
abortEarly: false,
stripUnknown: true
});
if (error) {
const errors = error.details.map(detail => ({
field: detail.path.join('.'),
message: detail.message
}));
return res.status(400).json({
error: 'Query validation failed',
errors
});
}
req.query = value;
next();
};
};
/**
* Common validation schemas
*/
export const commonSchemas = {
// Pagination
pagination: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sort: Joi.string(),
order: Joi.string().valid('asc', 'desc').default('desc')
}),
// Date range
dateRange: Joi.object({
startDate: Joi.date().iso(),
endDate: Joi.date().iso().min(Joi.ref('startDate'))
}),
// UUID
uuid: Joi.string().uuid({ version: 'uuidv4' }),
// Percentage
percentage: Joi.number().min(0).max(100)
};