UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

774 lines 32.8 kB
/** * AnalyticsEngine - Main orchestrator for the Dynamic Analytics Query Engine */ import { getLogger } from '../logging/Logger.js'; import { IntentParser } from './IntentParser.js'; import { HybridQueryBuilder } from './HybridQueryBuilder.js'; import { JSONataProcessor } from './JSONataProcessor.js'; import { QueryValidator } from './QueryValidator.js'; import { InsightsEngine } from './InsightsEngine.js'; import { AnalyticalFunctions } from './AnalyticalFunctions.js'; import { AnalyticsTransformer } from './AnalyticsTransformer.js'; import { QueryCache } from './QueryCache.js'; import { QueryOptimizer } from './QueryOptimizer.js'; import { ERROR_CODES, QUERY_LIMITS } from './constants.js'; import { HardStopError, HardStopErrorType } from '../errors/HardStopError.js'; export class AnalyticsEngine { intentParser; queryBuilder; jsonataProcessor; queryValidator; insightsEngine; analyticalFunctions; analyticsTransformer; queryCache; queryOptimizer; db; activeSessions = new Map(); constructor(database, permissions) { this.db = database; this.intentParser = new IntentParser(); this.queryBuilder = new HybridQueryBuilder(); this.jsonataProcessor = new JSONataProcessor(); this.queryValidator = new QueryValidator(permissions); this.insightsEngine = new InsightsEngine(); this.analyticalFunctions = new AnalyticalFunctions(); this.analyticsTransformer = new AnalyticsTransformer(); // Initialize cache and optimizer this.queryCache = new QueryCache({ maxSize: parseInt(process.env.ANALYTICS_CACHE_SIZE || '1000'), defaultTTL: parseInt(process.env.ANALYTICS_CACHE_TTL || '300000'), // 5 minutes maxMemory: parseInt(process.env.ANALYTICS_CACHE_MEMORY || '104857600') // 100MB }); this.queryOptimizer = new QueryOptimizer(database); } /** * Main entry point for analytics queries */ async analyze(input, options = {}) { const startTime = Date.now(); try { // Check if the query is asking for keys only const isKeysOnlyQuery = typeof input === 'string' && (input.toLowerCase().includes('keys') || (input.toLowerCase().includes('list all') && input.toLowerCase().includes('key'))); // Add keysOnly to options for passing to processResults const enhancedOptions = { ...options, keysOnly: isKeysOnlyQuery }; // Step 1: Determine input type and parse intent const intent = await this.parseInput(input, enhancedOptions); // Step 1.5: Apply project_id filter if provided in options if (enhancedOptions.projectId) { // Add project_id filter to the intent if (!intent.filters) { intent.filters = []; } intent.filters.push({ field: 'project_id', operator: 'eq', value: enhancedOptions.projectId }); getLogger().debug({ projectId: enhancedOptions.projectId, addedFilter: true }, 'AnalyticsEngine: Added project_id filter to intent'); } // Log detected entity type for debugging getLogger().debug({ inputType: typeof input, input: typeof input === 'string' ? input : 'structured', detectedEntity: intent.primaryEntity, isKeysOnly: isKeysOnlyQuery, filtersCount: intent.filters?.length || 0 }, 'AnalyticsEngine: Parsed intent'); // Step 2: Validate the query intent const validationResult = this.queryValidator.validateIntent(intent); if (!validationResult.valid) { return this.createErrorResult(ERROR_CODES.VALIDATION_ERROR, 'Query validation failed', validationResult.errors); } // Step 3: Build the query const query = await this.queryBuilder.buildHybridQuery(intent); // Step 3.5: Check cache for existing results const cachedResult = this.queryCache.get(query, query.params); if (cachedResult && !enhancedOptions.noCache) { getLogger().debug({ cacheHit: true, query: query.sql }, 'AnalyticsEngine: Returning cached result'); return cachedResult; } // Step 3.6: Optimize the query const optimizationResult = this.queryOptimizer.optimize(query, intent); const optimizedQuery = optimizationResult.optimizedQuery; if (optimizationResult.optimizations.length > 0) { getLogger().info({ originalQuery: query.sql, optimizedQuery: optimizedQuery.sql, optimizations: optimizationResult.optimizations, estimatedCost: optimizationResult.estimatedCost }, 'AnalyticsEngine: Query optimized'); } // Step 4: Validate the compiled query const queryValidation = optimizedQuery.type === 'hybrid' ? this.queryValidator.validateHybridQuery(optimizedQuery) : this.queryValidator.validateCompiledQuery({ sql: optimizedQuery.sql, params: optimizedQuery.params }); if (!queryValidation.valid) { return this.createErrorResult(ERROR_CODES.VALIDATION_ERROR, 'Compiled query validation failed', queryValidation.errors); } // Step 5: Execute the optimized query getLogger().info({ sql: optimizedQuery.sql, params: optimizedQuery.params, type: optimizedQuery.type }, 'AnalyticsEngine: Executing query'); const results = await this.executeQuery(optimizedQuery, enhancedOptions); // Step 6: Process results if needed const processedResults = await this.processResults(results, optimizedQuery, intent, enhancedOptions); // Check if this is already a simplified response object (from AnalyticsTransformer) if (this.isSimplifiedResponse(processedResults)) { // Cache the simplified response if eligible if (optimizationResult.useCache && !enhancedOptions.noCache) { this.queryCache.set(optimizedQuery, processedResults, optimizedQuery.params, enhancedOptions.cacheTTL); } // Return simplified response directly without any wrapper return processedResults; } // Step 7: Generate insights for non-simplified responses const executionTime = Date.now() - startTime; const insights = this.insightsEngine.analyzeResults(processedResults, { queryIntent: intent, resultCount: Array.isArray(processedResults) ? processedResults.length : 0, executionTime, hasMoreResults: Array.isArray(processedResults) && processedResults.length >= (options.limit || QUERY_LIMITS.defaultLimit) }); // Step 8: Format and return results const finalResult = this.formatResults(processedResults, { query: optimizedQuery.sql, executionTime, rowCount: Array.isArray(processedResults) ? processedResults.length : 0, insights, optimizations: optimizationResult.optimizations }, options.format); // Step 9: Cache the result if eligible if (optimizationResult.useCache && !enhancedOptions.noCache) { this.queryCache.set(optimizedQuery, finalResult, optimizedQuery.params, enhancedOptions.cacheTTL); } return finalResult; } catch (error) { getLogger().error({ error }, 'Analytics engine error'); // Handle HardStopError specially if (error instanceof HardStopError) { return error.toResponse(); } return this.createErrorResult(ERROR_CODES.EXECUTION_ERROR, error.message || 'An error occurred during analysis'); } } /** * Get available templates */ getTemplates(category) { if (category) { return this.analyticalFunctions.getTemplatesByCategory(category); } return this.analyticalFunctions.getTemplates(); } /** * Handle interactive queries */ async handleInteractive(sessionId, input) { let session = this.activeSessions.get(sessionId); if (!session) { // Start new session session = { id: sessionId, state: 'initial', context: {}, history: [] }; this.activeSessions.set(sessionId, session); } // Process based on session state switch (session.state) { case 'initial': return this.handleInitialQuery(session, input); case 'clarifying': return this.handleClarification(session, input); default: return this.analyze(input); } } async parseInput(input, options) { // Handle different input types if (typeof input === 'string') { // Natural language query const intent = await this.intentParser.parse(input); // Ensure limit is set for natural language queries if (!intent.limit) { intent.limit = parseInt(process.env.ANALYTICS_DEFAULT_PAGE_SIZE || '10'); } return intent; } if (input.structured_query) { // Structured query object return this.parseStructuredQuery(input.structured_query); } if (input.template) { // Template-based query return this.analyticalFunctions.applyTemplate(input.template, input.template_params || {}); } if (input.query) { // Natural language in query field return this.intentParser.parse(input.query); } // Default: treat as structured query return this.parseStructuredQuery(input); } parseStructuredQuery(query) { const intent = { action: query.action || 'find', primaryEntity: query.find || query.entity || 'flags', filters: [], aggregations: [], // Apply default page size limit for simplified responses limit: query.limit || parseInt(process.env.ANALYTICS_DEFAULT_PAGE_SIZE || '10') }; // Parse filters if (query.filter) { intent.filters = this.parseStructuredFilters(query.filter); } // Parse grouping if (query.group_by) { if (typeof query.group_by === 'string') { intent.groupBy = [query.group_by]; } else if (Array.isArray(query.group_by)) { intent.groupBy = query.group_by; } else if (query.group_by.primary) { intent.groupBy = [query.group_by.primary]; if (query.group_by.secondary) { intent.groupBy.push(query.group_by.secondary); } } } // Parse transformations if (query.transform) { if (typeof query.transform === 'string') { // Parse simple transform intent.jsonataExpression = query.transform; intent.requiresJsonProcessing = true; } else if (Array.isArray(query.transform)) { intent.transforms = query.transform; intent.requiresJsonProcessing = true; } } // Parse output options if (query.output) { if (query.output.limit) { intent.limit = query.output.limit; } if (query.output.sort_by) { intent.orderBy = [{ field: query.output.sort_by, direction: query.output.sort_direction || 'desc' }]; } } return intent; } parseStructuredFilters(filters) { const result = []; for (const [key, value] of Object.entries(filters)) { if (key === 'has_variable') { result.push({ field: `data_json.variable_definitions.${value}`, operator: 'exists' }); } else if (key === 'environments' && Array.isArray(value)) { // Environment filtering requires special handling - it's not a direct column // This should be handled via JSON processing or joins result.push({ field: 'data_json', operator: 'json_contains', jsonPath: '$.environments', value }); } else if (typeof value === 'object' && value && 'operator' in value) { result.push({ field: key, operator: value.operator, value: value.value }); } else { result.push({ field: key, operator: 'eq', value }); } } return result; } async executeQuery(query, options) { try { // Add timeout handling const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Query timeout')), QUERY_LIMITS.queryTimeout); }); const queryPromise = new Promise((resolve, reject) => { try { const stmt = this.db.prepare(query.sql); const rawResults = stmt.all(...query.params); // Parse JSON fields to make data readable for AI agents const results = rawResults.map((row) => { const processedRow = { ...row }; // Parse data_json field if it exists and is a string if (processedRow.data_json && typeof processedRow.data_json === 'string') { try { processedRow.data_json = JSON.parse(processedRow.data_json); } catch (parseError) { // Keep as string if parsing fails getLogger().warn({ error: parseError, row: processedRow.id }, 'Failed to parse data_json field'); } } // Parse other common JSON fields const jsonFields = ['conditions', 'variables', 'audience_conditions']; for (const field of jsonFields) { if (processedRow[field] && typeof processedRow[field] === 'string') { try { processedRow[field] = JSON.parse(processedRow[field]); } catch (parseError) { // Keep as string if parsing fails } } } return processedRow; }); resolve(results); } catch (error) { // Enhanced error handling for SQL errors if (error.message?.includes('no such column')) { // Extract the problematic column name const columnMatch = error.message.match(/no such column: (\w+\.\w+)/); const column = columnMatch ? columnMatch[1] : 'unknown'; // Provide helpful context based on the column let helpMessage = `Database query failed: ${error.message}`; let reason = 'Database schema mismatch'; if (column.includes('.enabled') && column.startsWith('flags.')) { helpMessage = `The column "${column}" does not exist. Note that "enabled" is a property of environments, not flags directly.`; reason = `Invalid column reference: ${column} does not exist`; } else if (column.includes('.environment')) { helpMessage = `The column "${column}" does not exist. Environment data is stored in separate tables (flag_environments, experiments).`; reason = `Invalid column reference: ${column} does not exist`; } else { helpMessage = `The column "${column}" does not exist in the database schema.`; reason = `Invalid column reference: ${column}`; } // Throw HardStopError for database schema errors reject(new HardStopError(HardStopErrorType.DATABASE_SCHEMA_ERROR, reason, helpMessage, "ASK_USER", 400, ["retry", "continue", "auto_fix", "modify_query"])); } else { reject(error); } } }); return await Promise.race([queryPromise, timeoutPromise]); } catch (error) { if (error.message === 'Query timeout') { throw new Error(`Query exceeded timeout limit of ${QUERY_LIMITS.queryTimeout}ms`); } throw error; } } async processResults(results, query, intent, options) { if (query.type === 'sql-only' && !options.simplified) { return results; } let processed = results; // Apply JSONata expression if present if (query.jsonataExpression) { processed = await this.jsonataProcessor.processResults(processed, query.jsonataExpression, { options, intent }); } // Phase 2: Apply complex JSONata expression for advanced queries else if (query.type === 'hybrid' && intent.requiresJsonProcessing) { const complexExpression = this.jsonataProcessor.buildComplexExpression(intent); getLogger().debug({ complexExpression, intentAction: intent.action, intentGroupBy: intent.groupBy, intentMetrics: intent.metrics }, 'AnalyticsEngine: Applying complex JSONata expression'); processed = await this.jsonataProcessor.processResults(processed, complexExpression, { options, intent }); } // Apply processing pipeline if (query.processingPipeline) { for (const step of query.processingPipeline) { processed = await this.jsonataProcessor.applyProcessingStep(processed, step, { options, intent }); } } // Apply simplified transformation if requested or enabled by default const useSimplified = options.simplified || process.env.SIMPLIFIED_LIST_RESPONSES === 'true' || process.env.ANALYTICS_RESPONSE_MODE === 'simplified'; getLogger().debug({ optionsSimplified: options.simplified, envSimplifiedResponses: process.env.SIMPLIFIED_LIST_RESPONSES, envAnalyticsMode: process.env.ANALYTICS_RESPONSE_MODE, useSimplified, resultCount: processed.length }, 'AnalyticsEngine: Checking simplified transformation flags'); if (useSimplified || this.shouldUseSimplifiedTransformation(intent, processed)) { try { // Check if the query is asking for keys only (pass from outer scope) const isKeysOnlyQuery = options.keysOnly || false; // Normalize entity type - handle plural forms let entityType = intent.primaryEntity || intent.entityType || this.detectEntityType(processed); if (entityType === 'flags') entityType = 'flag'; if (entityType === 'experiments') entityType = 'experiment'; if (entityType === 'audiences') entityType = 'audience'; if (entityType === 'campaigns') entityType = 'campaign'; if (entityType === 'pages') entityType = 'page'; const transformedResponse = await this.analyticsTransformer.transformAnalyticsResults(processed, entityType, intent.platform || this.detectPlatform(processed), { pagination: options.pagination, environment_filter: intent.filters?.environment, project_id: options.projectId, project_name: intent.filters?.project_name, keysOnly: isKeysOnlyQuery }); // Return the paginated response structure directly return transformedResponse; } catch (transformError) { getLogger().warn({ error: transformError.message, intent: intent.entityType, resultCount: processed.length }, 'AnalyticsEngine: Simplified transformation failed, returning original results'); // Fallback to original results return processed; } } return processed; } /** * Determine if simplified transformation should be used */ shouldUseSimplifiedTransformation(intent, results) { // Check environment variable first - if set to true, always simplify if (process.env.SIMPLIFIED_LIST_RESPONSES === 'true' || process.env.ANALYTICS_RESPONSE_MODE === 'simplified') { return true; } // Use simplified transformation for large result sets OR any supported entity type const isLargeResultSet = results.length > 5; // Lowered threshold const supportedEntityTypes = ['flag', 'experiment', 'campaign', 'audience', 'page', 'attributes']; const entityType = intent.primaryEntity || intent.entityType || this.detectEntityType(results); // Always simplify if we have supported entity types and multiple results return (isLargeResultSet && supportedEntityTypes.includes(entityType)) || (results.length > 1 && entityType === 'flag'); // Always simplify multiple flags } /** * Detect entity type from results */ detectEntityType(results) { if (!results.length) return 'unknown'; const sample = results[0]; // Check for flag indicators - handle both Feature Experimentation and database structure if (sample.key && (sample.environments || sample.data_json || sample.environment_key)) { return 'flag'; } // Check for experiment indicators if (sample.variations && (sample.traffic_allocation !== undefined || sample.metrics)) { return 'experiment'; } // Check for campaign indicators if (sample.experiments && Array.isArray(sample.experiments)) { return 'campaign'; } // Check for audience indicators if (sample.conditions && (sample.name || sample.description)) { return 'audience'; } // Check for page indicators if (sample.page_type || sample.activation_code) { return 'page'; } // Default based on common field patterns if (sample.key && sample.name) { return 'entity'; // Generic entity } return 'unknown'; } /** * Detect platform from results */ detectPlatform(results) { if (!results.length) return 'auto'; const sample = results[0]; // Feature Experimentation indicators if (sample.environments && Array.isArray(sample.environments)) { return 'feature'; } if (sample.variables || sample.feature_enabled !== undefined) { return 'feature'; } // Web Experimentation indicators if (sample.page_ids || sample.actions || sample.changes) { return 'web'; } if (sample.campaign_id || sample.url_conditions) { return 'web'; } return 'auto'; } /** * Check if the result is already a simplified response object or keys array */ isSimplifiedResponse(result) { // Check for simplified response object if (result && typeof result === 'object' && !Array.isArray(result) && result.entities) { // Simplified check - only require entities field (pagination and summary are optional) return true; } // Check for keys-only array (strings) if (Array.isArray(result) && result.length > 0 && typeof result[0] === 'string') { return true; } return false; } formatResults(data, metadata, format) { const result = { data, metadata }; // Apply formatting based on requested format switch (format) { case 'table': result.data = this.formatAsTable(data); break; case 'summary': result.data = this.formatAsSummary(data, metadata.insights); break; case 'detailed': // Keep full data with metadata break; case 'csv': result.data = [{ csv: this.formatAsCsv(data) }]; break; default: // Return as-is (JSON) break; } return result; } formatAsTable(data) { if (data.length === 0) return { headers: [], rows: [] }; // Extract headers from first row const headers = Object.keys(data[0]); // Convert to array format const rows = data.map(row => headers.map(header => row[header])); return { headers, rows }; } formatAsSummary(data, insights) { const summary = { total_results: data.length, insights: insights.map(i => ({ type: i.type, title: i.title, description: i.description })) }; // Add key statistics if available if (data.length > 0) { const sample = data[0]; if (sample.count !== undefined) { summary.total_count = data.reduce((sum, row) => sum + (row.count || 0), 0); } } // Add top results summary.top_results = data.slice(0, 5); return summary; } formatAsCsv(data) { if (data.length === 0) return ''; const headers = Object.keys(data[0]); const rows = [ headers.join(','), ...data.map(row => headers.map(header => { const value = row[header]; // Escape values containing commas or quotes if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) { return `"${value.replace(/"/g, '""')}"`; } return value; }).join(',')) ]; return rows.join('\n'); } async handleInitialQuery(session, input) { // Parse initial query const intent = await this.parseInput(input, {}); // Check if clarification needed if (this.needsClarification(intent)) { session.state = 'clarifying'; session.context.intent = intent; return { data: [], metadata: { query: '', executionTime: 0, rowCount: 0, insights: [] }, error: { code: 'NEEDS_CLARIFICATION', message: this.generateClarificationQuestion(intent), details: { interaction_id: session.id, options: this.generateClarificationOptions(intent) } } }; } // Process normally return this.analyze(input); } async handleClarification(session, input) { // Apply clarification to saved intent const originalIntent = session.context.intent; const clarifiedIntent = this.applyClarification(originalIntent, input); // Continue with analysis session.state = 'processing'; return this.analyze(clarifiedIntent); } needsClarification(intent) { // Check if query is ambiguous if (!intent.primaryEntity) return true; if (intent.action === 'analyze' && !intent.metrics?.length) return true; if (intent.action === 'compare' && !intent.relatedEntities?.length) return true; return false; } generateClarificationQuestion(intent) { if (!intent.primaryEntity) { return 'What type of data would you like to analyze?'; } if (intent.action === 'analyze' && !intent.metrics?.length) { return 'What aspects would you like to analyze?'; } if (intent.action === 'compare' && !intent.relatedEntities?.length) { return 'What would you like to compare?'; } return 'Please clarify your query'; } generateClarificationOptions(intent) { const options = []; if (!intent.primaryEntity) { options.push({ id: 'flags', description: 'Feature flags', preview: 'Analyze feature flag usage and configuration' }, { id: 'experiments', description: 'Experiments', preview: 'Analyze experiment performance and setup' }, { id: 'audiences', description: 'Audiences', preview: 'Analyze audience targeting and usage' }); } return options; } applyClarification(intent, clarification) { const clarified = { ...intent }; if (clarification.selected_option) { clarified.primaryEntity = clarification.selected_option; } if (clarification.metrics) { clarified.metrics = clarification.metrics; } return clarified; } createErrorResult(code, message, details) { return { data: [], error: { code, message, details } }; } /** * Update permissions for the validator */ updatePermissions(permissions) { this.queryValidator.updatePermissions(permissions); } /** * Get cache statistics */ getCacheStats() { return this.queryCache.getStats(); } /** * Clear query cache */ clearCache() { this.queryCache.clear(); this.queryOptimizer.clearCache(); getLogger().info('AnalyticsEngine: Cleared all caches'); } /** * Warm up cache with common queries */ async warmupCache() { const commonQueries = [ 'list all flags', 'list all experiments', 'show active flags', 'show running experiments' ]; for (const query of commonQueries) { try { await this.analyze(query, { noCache: false }); } catch (error) { // Ignore warmup errors } } getLogger().info({ warmedQueries: commonQueries.length, cacheStats: this.getCacheStats() }, 'AnalyticsEngine: Cache warmed up'); } /** * Clean up resources */ cleanup() { // Clear active sessions this.activeSessions.clear(); // Clear caches this.clearCache(); } } //# sourceMappingURL=AnalyticsEngine.js.map