@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
818 lines (817 loc) • 286 kB
JavaScript
/**
* Entity Orchestrator for Complex Multi-Step Entity Creation
* @description Handles complex entity creation workflows that require multiple API calls
* in a specific sequence with rollback capabilities for partial failures.
*
* Key Responsibilities:
* - Orchestrate multi-step entity creation (e.g., Flag + Variables + A/B Test)
* - Handle dependencies between entity creation steps
* - Provide rollback functionality for partial failures
* - Track creation progress for debugging and error reporting
*
* @author Optimizely MCP Server
* @version 1.0.0
*/
import { getLogger } from '../logging/Logger.js';
import { safeLogObject } from '../logging/LoggingUtils.js';
import { TemplateProcessor } from '../utils/TemplateProcessor.js';
import { EntityRouter } from '../tools/EntityRouter.js';
import { TrafficAllocationCalculator } from '../utils/TrafficAllocationCalculator.js';
import { ComprehensiveAutoCorrector } from '../validation/ComprehensiveAutoCorrector.js';
import { extractRefs } from '../templates/ModelFriendlyTemplates.js';
import { MCPErrorMapper } from '../errors/MCPErrorMapping.js';
import { HardStopErrorType } from '../errors/HardStopError.js';
import { EntityMatcher } from '../utils/EntityMatcher.js';
import { AudienceReferenceResolver } from '../utils/AudienceReferenceResolver.js';
import { safeIdToString } from '../utils/SafeIdConverter.js';
import { OptimizelyMCPTools } from '../tools/OptimizelyMCPTools.js';
/**
* Entity Orchestrator Class
* Manages complex multi-step entity creation workflows
*/
export class EntityOrchestrator {
apiHelper;
entityRouter;
logger;
entityReferenceCache; // Cache for resolved entity references
autoDecisions = []; // Track auto decisions
cacheManager;
mcpTools;
constructor(apiHelper, cacheManager, storage, existingEntityRouter) {
this.apiHelper = apiHelper;
this.cacheManager = cacheManager;
// 🔧 FIX: Use existing EntityRouter instance if provided, otherwise create new one
this.entityRouter = existingEntityRouter || new EntityRouter(apiHelper, cacheManager, storage);
this.logger = getLogger();
this.entityReferenceCache = new Map();
}
/**
* Get or create OptimizelyMCPTools instance
* @returns OptimizelyMCPTools instance
*/
getMCPTools() {
if (!this.mcpTools) {
this.mcpTools = new OptimizelyMCPTools(this.cacheManager);
}
return this.mcpTools;
}
/**
* Helper method to detect "Everyone" audience references
* "Everyone" is a special virtual audience that doesn't exist in the database
*/
isEveryoneAudience(audienceInput) {
if (typeof audienceInput === 'string') {
return audienceInput.toLowerCase() === 'everyone';
}
if (audienceInput?.ref) {
const ref = audienceInput.ref;
return (ref.name && ref.name.toLowerCase() === 'everyone') ||
(ref.key && ref.key.toLowerCase() === 'everyone');
}
return false;
}
/**
* Create a feature flag with optional variables and A/B test
* @param projectId - Project ID
* @param processedTemplate - Processed template data
* @returns Orchestration result
*/
async createFlagWithABTest(projectId, processedTemplate) {
const startTime = Date.now();
const steps = [];
const createdEntities = [];
const errors = [];
const rollbackLog = [];
// CRITICAL DEBUG: Check if this method is called at all
this.logger.error('🚨 DEBUG: createFlagWithABTest ENTRY - METHOD CALLED', {
projectId,
hasProcessedTemplate: !!processedTemplate,
processedDataKeys: processedTemplate?.processedData ? Object.keys(processedTemplate.processedData) : 'no processedData'
});
// ULTRA DEBUG: Log exact parameters received
this.logger.error('🚨🚨🚨 createFlagWithABTest ENTRY - EXACT PARAMETERS', {
param1_projectId: projectId,
param1_type: typeof projectId,
param1_isString: typeof projectId === 'string',
param1_keys: typeof projectId === 'object' && projectId !== null ? Object.keys(projectId) : 'N/A',
param2_type: typeof processedTemplate,
param2_keys: processedTemplate ? Object.keys(processedTemplate) : 'null',
stackTrace: new Error().stack
});
this.logger.error('**** createFlagWithABTest METHOD STARTED ****', {
projectId,
processedTemplateKeys: Object.keys(processedTemplate),
processedDataKeys: processedTemplate.processedData ? Object.keys(processedTemplate.processedData) : 'null',
hasAbTest: !!processedTemplate.processedData?.ab_test
});
try {
const templateData = processedTemplate.processedData;
this.logger.error('**** TEMPLATE DATA IN createFlagWithABTest ****', {
fullTemplateData: JSON.stringify(templateData, null, 2)
});
// UNIFIED STRUCTURE HANDLING: Support both nested and flat structures
// Flat structure: { key: "flag_key", name: "Flag Name", ab_test: {...} }
// Nested structure: { flag: { key: "flag_key", name: "Flag Name" }, ab_test: {...} }
const isFlat = !templateData.flag && (templateData.key || templateData.name) && templateData.ab_test;
const flagData = isFlat ? {
key: templateData.key,
name: templateData.name,
description: templateData.description
} : templateData.flag;
this.logger.error('**** FLAG DATA EXTRACTED ****', {
isFlat,
flagData: JSON.stringify(flagData, null, 2),
hasKey: !!flagData?.key,
hasName: !!flagData?.name
});
// CRITICAL DEBUG: Track description field at entry
this.logger.info({
method: 'createFlagWithABTest',
debugPoint: 'ENTRY',
isFlat,
flagDescription: flagData?.description,
flagDescriptionType: typeof flagData?.description,
flagDescriptionIsArray: Array.isArray(flagData?.description),
topLevelDescription: templateData.description,
topLevelDescriptionType: typeof templateData.description,
topLevelDescriptionIsArray: Array.isArray(templateData.description),
templateDataKeys: Object.keys(templateData),
flagKeys: flagData ? Object.keys(flagData) : 'no flag object'
}, 'DESCRIPTION BUG TRACE: createFlagWithABTest ENTRY with unified structure handling');
this.logger.info('EntityOrchestrator: Starting flag creation with A/B test', { projectId, flagKey: flagData?.key });
// Determine platform for proper validation
const projectTypeInfo = await this.entityRouter.getProjectType(projectId);
const platform = projectTypeInfo.projectType;
// Step 1: Create the flag (basic flag without variations)
if (flagData) {
// CRITICAL DEBUG: Log flag data before creating
this.logger.info({
method: 'createFlagWithABTest',
debugPoint: 'BEFORE_FLAG_CREATE',
flagData: flagData,
flagDescription: flagData.description,
flagDescriptionType: typeof flagData.description,
flagDescriptionIsArray: Array.isArray(flagData.description)
}, 'DESCRIPTION BUG TRACE: About to create flag');
steps.push({
stepName: 'create_flag',
operation: 'create',
entityType: 'flag',
data: flagData
});
}
// Step 2: Create variable definitions FIRST (if provided)
// Variable definitions must be created before variations that reference them
if (templateData.variables && Array.isArray(templateData.variables)) {
templateData.variables.forEach((variable, index) => {
steps.push({
stepName: `create_variable_${index}`,
operation: 'create',
entityType: 'variable_definition',
data: variable,
dependencies: ['create_flag'],
options: {
flag_key: flagData.key,
platform: platform // Add platform for consistency
}
});
});
}
// CRITICAL EARLY FIX: Ensure ab_test structure exists with all required properties
// This MUST happen before any code tries to access ab_test.variations or ab_test.traffic_allocation
if (templateData.ab_test) {
if (!templateData.ab_test.variations) {
this.logger.info('EntityOrchestrator: Early fix - creating default variations array', {
flagKey: flagData.key,
reason: 'No variations provided in template'
});
templateData.ab_test.variations = [
{
key: "control",
name: "Control",
description: "Control variation (auto-generated)"
}
];
}
if (!templateData.ab_test.traffic_allocation) {
this.logger.info('EntityOrchestrator: Early fix - creating default traffic_allocation', {
flagKey: flagData.key
});
templateData.ab_test.traffic_allocation = {
"control": 10000 // 100% in basis points
};
}
}
// Step 3: Create variations for the flag
// NOTE: In Feature Experimentation, variations ARE separate entities that must be created
// before they can be referenced in rules/rulesets. This is different from Web Experimentation.
// Reference: https://github.com/optimizely/fx-api-cookbook
if (templateData.ab_test?.variations && Array.isArray(templateData.ab_test.variations)) {
// Validate that flag key exists for variations
if (!flagData?.key) {
throw new Error('Flag key is required for creating variations. Ensure flag key is defined.');
}
// Build dependencies for variations - they need flag and variable definitions
const variationDependencies = ['create_flag'];
if (templateData.variables && templateData.variables.length > 0) {
// Add all variable definitions as dependencies
variationDependencies.push(...templateData.variables.map((_, i) => `create_variable_${i}`));
}
templateData.ab_test.variations.forEach((variation, index) => {
// Get traffic allocation for this variation (default to equal distribution)
const totalVariations = templateData.ab_test.variations.length;
const defaultWeight = Math.floor(10000 / totalVariations);
const trafficAllocation = templateData.ab_test.traffic_allocation || {};
const weight = trafficAllocation[variation.key] || defaultWeight;
// CRITICAL: DO NOT REMOVE flag_key FROM variationData
// Without flag_key, variations will NOT be cached in the database
// This causes "Invalid keys for Ruleset" errors because template variables can't resolve
// The cache logic checks: if (entityForStorage.key && entityForStorage.flag_key)
// Fixed: January 27, 2025 - Regression from previous working code
const variationData = {
key: variation.key,
name: variation.name,
description: variation.description || `Variation ${index + 1}`,
// CRITICAL FIX: Include flag_key in data for cache persistence
flag_key: flagData.key
};
// Include variable values if specified in the template
// Transform to Feature Experimentation API format: { variable_key: { value: "actual_value" } }
if (variation.variable_values && Object.keys(variation.variable_values).length > 0) {
variationData.variables = {};
for (const [variableKey, variableValue] of Object.entries(variation.variable_values)) {
variationData.variables[variableKey] = { value: variableValue };
}
}
steps.push({
stepName: `create_variation_${index}`,
operation: 'create',
entityType: 'variation',
data: variationData,
dependencies: variationDependencies,
options: {
flag_key: flagData.key,
platform: platform // Use detected platform for proper validation
}
});
});
}
// Step 4: Create events/metrics if specified
// Support multiple ways to specify metrics/events:
// 1. ab_test.metrics array (new format with refs)
// 2. ab_test.primary_metric (legacy format)
// 3. ab_test.event_key (simple format)
let metrics = [];
if (templateData.ab_test?.metrics && Array.isArray(templateData.ab_test.metrics)) {
// Process each metric, handling references
this.logger.info(`EntityOrchestrator: Processing ab_test.metrics - Count: ${templateData.ab_test.metrics.length}, Data: ${JSON.stringify(templateData.ab_test.metrics)}`);
for (const metric of templateData.ab_test.metrics) {
this.logger.info(`EntityOrchestrator: Processing individual metric: ${JSON.stringify(metric)}`);
if (metric.ref) {
// Handle metric reference
if (metric.ref.auto_create && metric.ref.template) {
// Auto-create event from template
const eventTemplate = metric.ref.template;
const metricToAdd = {
event_key: eventTemplate.key,
event_name: eventTemplate.name,
event_type: eventTemplate.event_type || 'custom',
aggregator: eventTemplate.aggregator || metric.aggregator || 'unique',
scope: metric.scope || 'visitor',
winning_direction: metric.winning_direction || 'increasing',
auto_create: true
};
this.logger.info(`EntityOrchestrator: Adding auto-create metric: ${JSON.stringify(metricToAdd)}`);
metrics.push(metricToAdd);
}
else if (metric.ref.key || metric.ref.id) {
// Reference to existing event - mark for entity resolution
const metricToAdd = {
event_key: metric.ref.key || metric.ref.id,
aggregator: metric.aggregator || 'unique',
scope: metric.scope || 'visitor',
winning_direction: metric.winning_direction || 'increasing',
// CRITICAL FIX: Mark as reference so it gets resolved during orchestration
is_reference: true,
ref_key: metric.ref.key,
ref_id: metric.ref.id
};
this.logger.info(`EntityOrchestrator: Adding referenced metric for resolution: ${JSON.stringify(metricToAdd)}`);
metrics.push(metricToAdd);
}
}
else if (metric.event_key || metric.display_title) {
// Direct metric specification
const metricToAdd = {
...metric,
event_key: metric.event_key || metric.display_title
};
this.logger.info(`EntityOrchestrator: Adding direct metric: ${JSON.stringify(metricToAdd)}`);
metrics.push(metricToAdd);
}
}
}
else if (templateData.ab_test?.primary_metric) {
metrics = [{ event_key: templateData.ab_test.primary_metric, aggregator: 'count' }];
}
else if (templateData.ab_test?.event_key) {
metrics = [{ event_key: templateData.ab_test.event_key, aggregator: 'count' }];
}
// Also check for metrics at the root level (fallback)
if (metrics.length === 0 && templateData.metrics && Array.isArray(templateData.metrics)) {
// Process root-level metrics just like ab_test.metrics
for (const metric of templateData.metrics) {
if (metric.ref) {
// Handle metric reference
if (metric.ref.auto_create && metric.ref.template) {
// Auto-create event from template
const eventTemplate = metric.ref.template;
metrics.push({
event_key: eventTemplate.key,
event_name: eventTemplate.name,
event_type: eventTemplate.event_type || 'custom',
aggregator: eventTemplate.aggregator || metric.aggregator || 'unique',
scope: metric.scope || 'visitor',
winning_direction: metric.winning_direction || 'increasing',
auto_create: true
});
}
else if (metric.ref.key || metric.ref.id) {
// Reference to existing event
metrics.push({
event_key: metric.ref.key || metric.ref.id,
aggregator: metric.aggregator || 'unique',
scope: metric.scope || 'visitor',
winning_direction: metric.winning_direction || 'increasing'
});
}
}
else if (metric.event_key || metric.display_title) {
// Direct metric specification
metrics.push({
...metric,
event_key: metric.event_key || metric.display_title
});
}
}
}
// Debug logging for metrics detection
this.logger.info('EntityOrchestrator: Metrics detection results', {
metricsFound: metrics.length,
metrics: metrics.map((m) => ({
event_key: m.event_key,
event_name: m.event_name,
auto_create: m.auto_create,
display_title: m.display_title,
aggregator: m.aggregator
})),
templateDataAbTest: templateData.ab_test ? {
hasMetrics: !!templateData.ab_test.metrics,
metricsLength: templateData.ab_test.metrics?.length || 0,
rawMetrics: templateData.ab_test.metrics,
primary_metric: templateData.ab_test.primary_metric,
event_key: templateData.ab_test.event_key
} : null,
templateDataMetrics: templateData.metrics ? {
hasMetrics: !!templateData.metrics,
metricsLength: templateData.metrics?.length || 0
} : null
});
if (metrics.length > 0) {
// Process ALL metrics - both auto-create and references
let eventStepIndex = 0;
// First, create events that are marked for auto-creation
const eventsToCreate = metrics.filter((m) => m.auto_create && m.event_key);
this.logger.info('EntityOrchestrator: Events to create analysis', {
totalMetrics: metrics.length,
eventsToCreateCount: eventsToCreate.length,
eventsToCreate: eventsToCreate.map((m) => ({
event_key: m.event_key,
event_name: m.event_name,
auto_create: m.auto_create
}))
});
for (const metric of eventsToCreate) {
const eventKey = metric.event_key;
steps.push({
stepName: `create_event_${eventStepIndex}`,
operation: 'create',
entityType: 'event',
data: {
key: eventKey,
name: metric.event_name || eventKey.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
event_type: metric.event_type || 'custom',
description: `Metric event for ${flagData.name} A/B test`
// NOTE: If you need to create events with properties, add event_properties here:
// event_properties: metric.event_properties || []
// The createEvent method in OptimizelyAPIHelper.ts must include the event_properties field
// See: __context_handoff/EVENT-PROPERTIES-FIX-JULY-14-2025.md
},
// Make event creation optional - if it fails, we'll try to adopt existing
// This prevents the orchestration from stopping if event already exists
optional: true
});
eventStepIndex++;
}
// Then, verify referenced events exist (without auto_create)
const referencedEvents = metrics.filter((m) => !m.auto_create && m.event_key);
for (const metric of referencedEvents) {
const eventKey = metric.event_key;
// CRITICAL FIX: Use create_event_X naming to match template variable expectations
// Template variables expect ${create_event_0.id}, not ${verify_event_0.id}
steps.push({
stepName: `create_event_${eventStepIndex}`,
operation: 'get',
entityType: 'event',
data: {
key: eventKey
},
// Mark as reference resolution step
optional: false // Make this required since template variable depends on it
});
eventStepIndex++;
}
}
else {
// If no explicit metrics provided, create a default click event for the A/B test
// Use a clear naming convention that shows this is auto-generated
this.logger.error('EntityOrchestrator: CREATING DEFAULT METRIC - This should not happen with the provided payload!', {
metricsLength: metrics.length,
metrics: metrics,
templateDataAbTest: templateData.ab_test,
hasAbTestMetrics: !!templateData.ab_test?.metrics,
abTestMetricsLength: templateData.ab_test?.metrics?.length
});
const defaultEventKey = `${flagData.key}_default_metric`;
steps.push({
stepName: 'create_event_0',
operation: 'create',
entityType: 'event',
data: {
key: defaultEventKey,
name: `${flagData.name} Default Metric`,
event_type: 'custom',
description: `Auto-generated default metric for ${flagData.name} A/B test (no metrics specified in template)`
},
// Make default event creation optional too
optional: true
});
// Add this default event to metrics for the ruleset configuration
metrics = [{ event_key: defaultEventKey, aggregator: 'unique' }];
this.logger.warn('EntityOrchestrator: No metrics specified in template, creating default metric', {
flagKey: flagData.key,
defaultEventKey,
hint: 'To specify custom metrics, include ab_test.metrics array in your template'
});
}
// Step 5: Update the ruleset to add A/B test configuration
if (templateData.ab_test) {
// DEBUG: Log what we're receiving
this.logger.info('DEBUG: templateData.ab_test structure', {
hasAbTest: !!templateData.ab_test,
abTestKeys: templateData.ab_test ? Object.keys(templateData.ab_test) : 'null',
environment: templateData.ab_test?.environment,
environmentType: typeof templateData.ab_test?.environment,
fullAbTest: JSON.stringify(templateData.ab_test)
});
// Resolve environment reference if needed
let environmentKey;
if (templateData.ab_test.environment && typeof templateData.ab_test.environment === 'object' && templateData.ab_test.environment.ref) {
// Environment is provided as a reference
const envRef = templateData.ab_test.environment.ref;
if (envRef.key) {
environmentKey = envRef.key;
this.logger.info('EntityOrchestrator: Resolved environment reference', {
envRef,
environmentKey
});
}
else {
throw new Error('Environment reference must include a key. Please specify environment.ref.key (e.g., "production", "staging", "development")');
}
}
else if (typeof templateData.ab_test.environment === 'string') {
// Environment is provided as a string key
environmentKey = templateData.ab_test.environment;
}
else if (typeof templateData.ab_test.environment === 'number' || (typeof templateData.ab_test.environment === 'string' && /^\d+$/.test(templateData.ab_test.environment))) {
// Environment is already resolved to an ID (from reference resolution)
const environmentId = Number(templateData.ab_test.environment);
this.logger.info({
environmentId,
originalValue: templateData.ab_test.environment
}, 'EntityOrchestrator: Environment already resolved to ID');
// For EntityOrchestrator, we need the environment key, not ID
// Try to find the environment by ID to get its key
try {
const envResult = await this.entityRouter.routeEntityOperation({
operation: 'get',
entityType: 'environment',
entityId: environmentId.toString(),
projectId
});
// Debug logging to see what we actually received
this.logger.info({
environmentId,
envResult,
hasKey: !!envResult?.key,
resultType: typeof envResult,
resultKeys: envResult ? Object.keys(envResult) : 'null/undefined'
}, 'EntityOrchestrator: Environment lookup result details');
// Handle response envelope format
const actualEnv = envResult?.data || envResult;
if (actualEnv && actualEnv.key) {
environmentKey = actualEnv.key;
this.logger.info({
environmentId,
environmentKey
}, 'EntityOrchestrator: Retrieved environment key from resolved ID');
}
else {
throw new Error(`Environment with ID ${environmentId} not found or has no key. Received: ${JSON.stringify(actualEnv)}`);
}
}
catch (error) {
throw new Error(`Failed to resolve environment ID ${environmentId} to key: ${error.message}`);
}
}
else {
throw new Error('Environment is required for A/B test configuration. Please specify an environment key (e.g., "production", "staging", "development"). You can list available environments using: list_entities environment project_id=YOUR_PROJECT_ID');
}
// 🚨 CRITICAL FIX: Copy audiences from template level to ab_test level
// In template mode, audiences field exists at templateData.audiences, but AudienceReferenceResolver
// looks for it at templateData.ab_test.audiences. We need to bridge this gap.
if (templateData.audiences && Array.isArray(templateData.audiences) && templateData.audiences.length > 0) {
this.logger.info('EntityOrchestrator: Copying audiences from template level to ab_test level for processing', {
audienceCount: templateData.audiences.length,
operator: templateData.audiences_operator || 'or'
});
// Ensure ab_test object exists
if (!templateData.ab_test) {
templateData.ab_test = {};
}
// Copy audiences and operator to ab_test level so AudienceReferenceResolver can process them
templateData.ab_test.audiences = templateData.audiences;
templateData.ab_test.audiences_operator = templateData.audiences_operator;
}
// 🛡️ ROBUST AUDIENCE RESOLUTION - Defense-in-depth approach
// This resolver handles ALL possible audience reference formats and prevents
// format mismatch errors by normalizing to the correct API format
const audienceResolver = new AudienceReferenceResolver(this.entityRouter);
// CRITICAL DEBUG: Log ab_test structure and audience extraction
this.logger.error('🚨 AUDIENCE DEBUG: ab_test structure', {
hasAbTest: !!templateData.ab_test,
abTestKeys: templateData.ab_test ? Object.keys(templateData.ab_test) : 'no ab_test',
audience: templateData.ab_test?.audience,
audiences: templateData.ab_test?.audiences,
audience_conditions: templateData.ab_test?.audience_conditions
});
const audienceInput = AudienceReferenceResolver.extractAudienceFromABTest(templateData.ab_test);
this.logger.error('🚨 AUDIENCE DEBUG: extraction result', {
audienceInput,
audienceInputType: typeof audienceInput,
audienceInputNull: audienceInput === null
});
if (audienceInput) {
this.logger.info('EntityOrchestrator: Starting robust audience resolution', {
audienceInput: JSON.stringify(audienceInput),
projectId,
hasRef: !!audienceInput?.ref,
hasAutoCreate: !!audienceInput?.ref?.auto_create
});
try {
// CRITICAL: Special handling for "Everyone" audience - DO NOT REMOVE
const isEveryoneAudience = this.isEveryoneAudience(audienceInput);
if (isEveryoneAudience) {
templateData.ab_test.audience_conditions = 'everyone';
this.logger.info('EntityOrchestrator: Resolved "Everyone" audience to target all users');
}
else {
// 🚨 CRITICAL FIX: Handle multiple audiences array before single audience processing
if (Array.isArray(audienceInput)) {
this.logger.info('EntityOrchestrator: Detected multiple audiences array, processing directly', {
audienceCount: audienceInput.length
});
// Multiple audiences detected - process directly without using resolveAudienceReference
// Call applyAudienceConditions which now has logic to handle multiple audiences
AudienceReferenceResolver.applyAudienceConditions(templateData.ab_test, []);
this.logger.info('EntityOrchestrator: Multiple audiences processed by AudienceReferenceResolver.applyAudienceConditions');
}
else {
// Single audience processing - use the existing flow
this.logger.error('🚨🚨🚨 BEFORE audienceResolver.resolveAudienceReference', {
audienceInput: JSON.stringify(audienceInput, null, 2),
projectId,
hasAudienceResolver: !!audienceResolver,
audienceInputStructure: {
hasRef: !!audienceInput?.ref,
hasAutoCreate: !!audienceInput?.ref?.auto_create,
hasTemplate: !!audienceInput?.ref?.template,
autoCreateValue: audienceInput?.ref?.auto_create,
templateValue: audienceInput?.ref?.template
}
});
const resolved = await audienceResolver.resolveAudienceReference(audienceInput, projectId);
this.logger.error('🚨🚨🚨 AFTER audienceResolver.resolveAudienceReference', {
resolved,
resolvedType: typeof resolved,
isNull: resolved === null,
hasCreated: resolved?.created,
hasCreatedAudience: resolved?.createdAudience,
audienceConditions: resolved?.audience_conditions
});
if (resolved) {
AudienceReferenceResolver.applyAudienceConditions(templateData.ab_test, resolved.audience_conditions);
// Check if this was created vs adopted
if (resolved.created && resolved.createdAudience) {
// Track as CREATED entity (not adopted)
this.logger.info('EntityOrchestrator: Auto-created audience from template', {
audienceId: resolved.createdAudience.id,
audienceName: resolved.createdAudience.name
});
// Track the created audience immediately
createdEntities.push({
stepName: 'auto_create_audience',
entityType: 'audience',
entityId: String(resolved.createdAudience.id),
entityKey: resolved.createdAudience.name,
rollbackData: resolved.createdAudience
});
this.logger.info('EntityOrchestrator: Added auto-created audience to createdEntities', {
audienceId: resolved.createdAudience.id,
audienceName: resolved.createdAudience.name,
createdEntitiesCount: createdEntities.length
});
// Don't add to steps - it's already created
}
else {
// Existing logic for adopted audiences
this.logger.info('EntityOrchestrator: Successfully resolved audience reference using robust resolver', {
sourceFormat: resolved.source_format,
transformationsApplied: resolved.transformations_applied.length,
audienceId: resolved.audience_conditions[1]?.audience_id
});
// 🎯 CRITICAL FIX: Add audience verification step to track adoption
// This ensures the resolved audience gets counted in adopted_count
const audienceId = resolved.audience_conditions[1]?.audience_id;
if (audienceId) {
steps.push({
stepName: 'verify_adopted_audience',
operation: 'get',
entityType: 'audience',
data: { id: safeIdToString(audienceId) }, // Convert to string for EntityRouter
optional: true // Don't fail orchestration if verification fails
});
this.logger.info('EntityOrchestrator: Added audience verification step for adoption tracking', {
audienceId,
stepName: 'verify_adopted_audience'
});
}
}
}
else {
this.logger.warn('EntityOrchestrator: No audience reference found - targeting everyone');
}
} // End of single audience processing
}
}
catch (error) {
this.logger.warn('EntityOrchestrator: Robust audience resolution failed - creating flag without audience targeting', {
error: error instanceof Error ? error.message : String(error),
audienceInput: JSON.stringify(audienceInput).substring(0, 100),
projectId
});
// Continue without audience conditions (targets everyone)
}
}
// CRITICAL DEBUG: Check if we reach this point
this.logger.error('🚨 DEBUG: REACHED AUDIENCE RESOLUTION SECTION', {
hasAbTest: !!templateData.ab_test,
abTestKeys: templateData.ab_test ? Object.keys(templateData.ab_test) : 'none'
});
// We don't need to get the existing ruleset anymore since we're using
// the "add" operation at index 0 instead of replacing the entire array
// In Feature Experimentation, A/B tests are configured by updating the ruleset
// Build the rule configuration for the A/B test
// Handle traffic allocation - use calculator for even distribution if not specified
let variationsMap = {};
let hasCustomAllocation = false;
// CRITICAL FIX: Use template variables to reference actual created variation keys
// The variations have been created in previous steps (create_variation_0, create_variation_1, etc.)
// We must use template variables like ${create_variation_0.key} instead of template keys
// This ensures we reference the ACTUAL variation keys that were created, not the template keys
// DO NOT CHANGE THIS LOGIC - it fixes the "Invalid keys for Ruleset" error
// DEBUG: Wrap the potentially problematic section in try-catch
try {
this.logger.info('EntityOrchestrator: Before Has Custom Allocation 751', {
audience_conditions: templateData.ab_test.audience_conditions,
traffic_allocation: templateData.ab_test.traffic_allocation
});
hasCustomAllocation = templateData.ab_test.traffic_allocation &&
typeof templateData.ab_test.traffic_allocation === 'object' &&
Object.keys(templateData.ab_test.traffic_allocation).length > 0;
}
catch (debugError) {
this.logger.error('ERROR IN TRAFFIC ALLOCATION SECTION', {
error: debugError.message,
stack: debugError.stack,
line: 'Around line 765'
});
throw debugError;
}
// CRITICAL FIX #2: Ensure variations exist before forEach (ruleset processing)
if (!templateData.ab_test?.variations) {
this.logger.error('🚨 RULESET FIX: Creating default "control" variation for ruleset processing', {
flagKey: flagData.key,
reason: 'No variations provided in template - creating for ruleset'
});
templateData.ab_test.variations = [
{
key: "control",
name: "Control",
description: "Control variation (auto-generated for ruleset)"
}
];
// Set 100% traffic allocation to the control variation
templateData.ab_test.traffic_allocation = {
"control": 10000 // 100% in basis points
};
}
if (hasCustomAllocation) {
// Use provided allocations but map to actual created variation keys
templateData.ab_test.variations.forEach((v, index) => {
const percentageStr = templateData.ab_test.traffic_allocation[v.key];
if (!percentageStr) {
throw new Error(`Missing traffic allocation for variation ${v.key}`);
}
this.logger.info('EntityOrchestrator: InsideHas Custom Allocation 787', {
audience_conditions: templateData.ab_test.audience_conditions,
traffic_allocation: templateData.ab_test.traffic_allocation,
hasCustomAllocation: hasCustomAllocation,
variationsMap: variationsMap
});
// CRITICAL: Use template variable to reference the actual created variation key
const actualVariationKey = `\${create_variation_${index}.key}`;
variationsMap[actualVariationKey] = {
key: actualVariationKey,
percentage_included: TrafficAllocationCalculator.percentageToBasisPoints(percentageStr),
// Add variable values for this variation if specified
...(v.variable_values && Object.keys(v.variable_values).length > 0 ? {
variables: this.transformVariableValues(v.variable_values)
} : {})
};
});
}
else {
// Calculate even distribution using the calculator
const variationCount = templateData.ab_test.variations.length;
this.logger.info('EntityOrchestrator: Calculate even distribution 809', {
audience_conditions: templateData.ab_test.audience_conditions,
traffic_allocation: templateData.ab_test.traffic_allocation,
hasCustomAllocation: hasCustomAllocation,
variationsMap: variationsMap
});
const allocations = TrafficAllocationCalculator.calculateBasisPointAllocations(variationCount);
templateData.ab_test.variations.forEach((v, index) => {
// CRITICAL: Use template variable to reference the actual created variation key
const actualVariationKey = `\${create_variation_${index}.key}`;
variationsMap[actualVariationKey] = {
key: actualVariationKey,
percentage_included: allocations[index],
// Add variable values for this variation if specified
...(v.variable_values && Object.keys(v.variable_values).length > 0 ? {
variables: this.transformVariableValues(v.variable_values)
} : {})
};
});
// Log the allocation for transparency
getLogger().info({
variationCount,
allocation: TrafficAllocationCalculator.getAllocationDescription(variationCount)
}, 'EntityOrchestrator: Auto-calculated traffic allocation with template variables');
// Track auto decision
this.trackAutoDecision('traffic_allocation', TrafficAllocationCalculator.getAllocationDescription(variationCount), 'Equal distribution using template variables for actual created keys');
}
// Pre-flight lookup for existing events to resolve event IDs directly
// This prevents dependency on event creation steps that might fail
const resolvedMetrics = await Promise.all(metrics.map(async (m, index) => {
const metric = {
aggregator: m.aggregator || 'unique',
display_title: m.event_key || m.display_title || 'Metric',
event_type: 'custom',
scope: m.scope || 'visitor',
winning_direction: m.winning_direction || 'increasing'
};
// Try to find existing event by key, ref.key, or ref.id (matching the resolution logic)
let eventIdentifier = null;
let identifierType = null;
if (m.event_key) {
eventIdentifier = m.event_key;
identifierType = 'event_key';
}
else if (m.ref && (m.ref.key || m.ref.id || m.ref.name)) {
// Handle ref patterns like the resolution logic does
if (m.ref.key) {
eventIdentifier = m.ref.key;
identifierType = 'ref.key';
}