@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
774 lines • 32.8 kB
JavaScript
/**
* 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