UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

818 lines (817 loc) 286 kB
/** * 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'; }