import { randomUUID } from 'node:crypto'; import type { DataSource } from '@buster/data-source'; import { assetPermissions, db, metricFiles, updateMessageEntries } from '@buster/database'; import { type ChartConfigProps, type DataMetadata, type MetricYml, MetricYmlSchema, } from '@buster/server-shared/metrics'; import { wrapTraced } from 'braintrust'; import * as yaml from 'yaml'; import { z } from 'zod'; import { getDataSource } from '../../../../utils/get-data-source'; import { createPermissionErrorMessage, validateSqlPermissions, } from '../../../../utils/sql-permissions'; import { createRawToolResultEntry } from '../../../shared/create-raw-llm-tool-result-entry'; import { trackFileAssociations } from '../../file-tracking-helper'; import { validateAndAdjustBarLineAxes } from '../helpers/bar-line-axis-validator'; import { createMetadataFromResults } from '../helpers/metadata-from-results'; import { ensureTimeFrameQuoted } from '../helpers/time-frame-helper'; import type { CreateMetricsContext, CreateMetricsInput, CreateMetricsOutput, CreateMetricsState, } from './create-metrics-tool'; import { CREATE_METRICS_TOOL_NAME } from './create-metrics-tool'; import { createCreateMetricsRawLlmMessageEntry, createCreateMetricsReasoningEntry, } from './helpers/create-metrics-transform-helper'; interface FileWithId { id: string; name: string; file_type: string; result_message?: string; results?: Record[]; created_at: string; updated_at: string; version_number: number; } interface FailedFileCreation { name: string; error: string; } interface MetricFileResult { success: boolean; error?: string; metricFile?: FileWithId; metricYml?: MetricYml; message?: string; results?: Record[]; } type VersionHistory = (typeof metricFiles.$inferSelect)['versionHistory']; // Helper function to create initial version history function createInitialMetricVersionHistory(metric: MetricYml, createdAt: string): VersionHistory { return { '1': { content: metric, updated_at: createdAt, version_number: 1, }, }; } interface ValidationResult { success: boolean; error?: string; message?: string; results?: Record[]; metadata?: QueryMetadata; } interface QueryMetadata { rowCount: number; totalRowCount: number; executionTime: number; limited: boolean; maxRows: number; } interface ResultMetadata { totalRowCount?: number | undefined; limited?: boolean | undefined; maxRows?: number | undefined; } const resultMetadataSchema = z.object({ totalRowCount: z.number().optional(), limited: z.boolean().optional(), maxRows: z.number().optional(), }); async function processMetricFile( file: { name: string; yml_content: string }, dataSourceId: string, dataSourceDialect: string, userId: string, metricId?: string ): Promise { try { // Ensure timeFrame values are properly quoted before parsing const fixedYmlContent = ensureTimeFrameQuoted(file.yml_content); // Parse and validate YAML const metricYml = yaml.parse(fixedYmlContent); const validatedMetricYml = MetricYmlSchema.parse(metricYml); // Validate and adjust bar/line chart axes let finalChartConfig: ChartConfigProps; try { finalChartConfig = validateAndAdjustBarLineAxes(validatedMetricYml.chartConfig); } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Invalid bar/line chart axis configuration', }; } // Create the final metric YML with the adjusted chart config const finalMetricYml: MetricYml = { ...validatedMetricYml, chartConfig: finalChartConfig, }; // Use provided metric ID from state or generate new one const id = metricId || randomUUID(); // Validate SQL by running it const sqlValidationResult = await validateSql( finalMetricYml.sql, dataSourceId, userId, dataSourceDialect ); if (!sqlValidationResult.success) { return { success: false, error: `The SQL query has an issue: ${sqlValidationResult.error}. Please check your query syntax.`, }; } // Create metric file object const now = new Date().toISOString(); const metricFile: FileWithId = { id, name: finalMetricYml.name, file_type: 'metric', result_message: sqlValidationResult.message || '', results: sqlValidationResult.results || [], created_at: now, updated_at: now, version_number: 1, }; return { success: true, metricFile, metricYml: finalMetricYml, message: sqlValidationResult.message || '', results: sqlValidationResult.results || [], }; } catch (error) { let errorMessage = 'Unknown error'; if (error instanceof z.ZodError) { // Return the actual Zod validation errors for better debugging const issues = error.issues .map((issue) => { const path = issue.path.length > 0 ? ` at path '${issue.path.join('.')}'` : ''; return `${issue.message}${path}`; }) .join('; '); errorMessage = `The metric configuration is invalid: ${issues}`; } else if (error instanceof Error) { if (error.message.includes('YAMLParseError')) { errorMessage = 'The YAML format is incorrect. Please check the syntax and indentation.'; } else { errorMessage = error.message; } } return { success: false, error: errorMessage, }; } } async function validateSql( sqlQuery: string, dataSourceId: string, userId: string, dataSourceSyntax?: string ): Promise { try { if (!sqlQuery.trim()) { return { success: false, error: 'SQL query cannot be empty' }; } // Basic SQL validation if (!sqlQuery.toLowerCase().includes('select')) { return { success: false, error: 'SQL query must contain SELECT statement' }; } if (!sqlQuery.toLowerCase().includes('from')) { return { success: false, error: 'SQL query must contain FROM clause' }; } // Validate permissions before attempting to get data source const permissionResult = await validateSqlPermissions(sqlQuery, userId, dataSourceSyntax); if (!permissionResult.isAuthorized) { return { success: false, error: createPermissionErrorMessage(permissionResult.unauthorizedTables), }; } // Get a new DataSource instance let dataSource: DataSource | null = null; try { dataSource = await getDataSource(dataSourceId); } catch (_error) { return { success: false, error: `Unable to connect to your data source. Please check that it's properly configured and accessible.`, }; } // Retry configuration for SQL validation const MAX_RETRIES = 3; const TIMEOUT_MS = 120000; // 120 seconds (2 minutes) per attempt for Snowflake queue handling const RETRY_DELAYS = [1000, 3000, 6000]; // 1s, 3s, 6s // Attempt execution with retries for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { // Execute the SQL query using the DataSource with row limit and timeout for validation // Use maxRows to limit results without modifying the SQL query (preserves Snowflake caching) const result = await dataSource.execute({ sql: sqlQuery, options: { maxRows: 1000, // Additional safety limit at adapter level timeout: TIMEOUT_MS, }, }); if (result.success) { const allResults = result.rows || []; // Truncate results to 25 records for display in validation const results = allResults.slice(0, 25); // Validate metadata with Zod schema for runtime safety const validatedMetadata = resultMetadataSchema.safeParse(result.metadata); const parsedMetadata: ResultMetadata | undefined = validatedMetadata.success ? validatedMetadata.data : undefined; const metadata: QueryMetadata = { rowCount: results.length, totalRowCount: parsedMetadata?.totalRowCount ?? allResults.length, executionTime: result.executionTime || 100, limited: parsedMetadata?.limited ?? false, maxRows: parsedMetadata?.maxRows ?? 5000, }; let message: string; if (allResults.length === 0) { message = 'Query executed successfully but returned no records'; } else if (result.metadata?.limited) { message = `Query validated successfully. Results were limited to ${result.metadata.maxRows} rows for memory protection (query may return more rows when executed)${results.length < allResults.length ? ` - showing first 25 of ${allResults.length} fetched` : ''}`; } else { message = `Query validated successfully and returned ${allResults.length} records${allResults.length > 25 ? ' (showing sample of first 25)' : ''}`; } return { success: true, message, results, metadata, }; } // Check if error is timeout-related const errorMessage = result.error?.message || 'Query execution failed'; const isTimeout = errorMessage.toLowerCase().includes('timeout') || errorMessage.toLowerCase().includes('timed out'); if (isTimeout && attempt < MAX_RETRIES) { // Wait before retry const delay = RETRY_DELAYS[attempt] || 6000; console.warn( `[create-metrics] SQL validation timeout on attempt ${attempt + 1}/${MAX_RETRIES + 1}. Retrying in ${delay}ms...`, { sqlPreview: `${sqlQuery.substring(0, 100)}...`, attempt: attempt + 1, nextDelay: delay, } ); await new Promise((resolve) => setTimeout(resolve, delay)); continue; // Retry } // Not a timeout or no more retries return { success: false, error: errorMessage, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'SQL validation failed'; const isTimeout = errorMessage.toLowerCase().includes('timeout') || errorMessage.toLowerCase().includes('timed out'); if (isTimeout && attempt < MAX_RETRIES) { // Wait before retry const delay = RETRY_DELAYS[attempt] || 6000; console.warn( `[create-metrics] SQL validation timeout (exception) on attempt ${attempt + 1}/${MAX_RETRIES + 1}. Retrying in ${delay}ms...`, { sqlPreview: `${sqlQuery.substring(0, 100)}...`, attempt: attempt + 1, nextDelay: delay, error: errorMessage, } ); await new Promise((resolve) => setTimeout(resolve, delay)); continue; // Retry } // Not a timeout or no more retries return { success: false, error: errorMessage, }; } } // Should not reach here, but just in case return { success: false, error: 'Max retries exceeded for SQL validation', }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'SQL validation failed', }; } } function generateResultMessage( createdFiles: FileWithId[], failedFiles: FailedFileCreation[] ): string { if (failedFiles.length === 0) { return `Successfully created ${createdFiles.length} metric files.`; } const successMsg = createdFiles.length > 0 ? `Successfully created ${createdFiles.length} metric files. ` : ''; const failures = failedFiles.map( (failure) => `Failed to create '${failure.name}': ${failure.error}.\n\nPlease recreate the metric from scratch rather than attempting to modify. This error could be due to:\n- Using a dataset that doesn't exist (please reevaluate the available datasets in the chat conversation)\n- Invalid configuration in the metric file\n- Special characters in the metric name or SQL query\n- Syntax errors in the SQL query` ); if (failures.length === 1) { return `${successMsg.trim()}${failures[0]}.`; } return `${successMsg}Failed to create ${failures.length} metric files:\n${failures.join('\n')}`; } // Main create metric files function const createMetricFiles = wrapTraced( async ( params: CreateMetricsInput, context: CreateMetricsContext, state?: CreateMetricsState ): Promise => { // Get context values const userId = context.userId; const organizationId = context.organizationId; const messageId = context.messageId; const dataSourceId = context.dataSourceId; const dataSourceSyntax = context.dataSourceSyntax || 'generic'; if (!dataSourceId) { return { message: 'Unable to identify the data source. Please refresh and try again.', files: [], failed_files: [], }; } if (!userId) { return { message: 'Unable to verify your identity. Please log in again.', files: [], failed_files: [], }; } if (!organizationId) { return { message: 'Unable to access your organization. Please check your permissions.', files: [], failed_files: [], }; } const files: FileWithId[] = []; const failedFiles: FailedFileCreation[] = []; // Process files concurrently, passing metric IDs from state const processResults = await Promise.allSettled( params.files.map(async (file, index) => { // Ensure file has required properties if (!file.name || !file.yml_content) { return { fileName: file.name || 'unknown', result: { success: false, error: 'Missing required file properties', }, }; } // Get metric ID from state if available const metricId = state?.files?.[index]?.id; const result = await processMetricFile( file as { name: string; yml_content: string }, dataSourceId, dataSourceSyntax, userId, typeof metricId === 'string' ? metricId : undefined ); return { fileName: file.name, result }; }) ); const successfulProcessing: Array<{ fileName: string; metricFile: FileWithId; metricYml: MetricYml; message: string; results: Record[]; }> = []; // Separate successful from failed processing for (const processResult of processResults) { if (processResult.status === 'fulfilled') { const { fileName, result } = processResult.value; if ( result.success && result.metricFile && result.metricYml && result.message && result.results ) { successfulProcessing.push({ fileName, metricFile: result.metricFile, metricYml: result.metricYml, message: result.message, results: result.results, }); } else { failedFiles.push({ name: fileName, error: result.error || 'Unknown error', }); } } else { failedFiles.push({ name: 'unknown', error: processResult.reason?.message || 'Processing failed', }); } } // Database operations if (successfulProcessing.length > 0) { try { await db.transaction(async (tx: typeof db) => { // Insert metric files const metricRecords = successfulProcessing.map((sp) => ({ id: sp.metricFile.id, name: sp.metricFile.name, fileName: sp.fileName, content: sp.metricYml, verification: 'notRequested' as const, evaluationObj: null, evaluationSummary: null, evaluationScore: null, organizationId, createdBy: userId, createdAt: sp.metricFile.created_at, updatedAt: sp.metricFile.updated_at, deletedAt: null, publiclyAccessible: false, publiclyEnabledBy: null, publicExpiryDate: null, versionHistory: createInitialMetricVersionHistory( sp.metricYml, sp.metricFile.created_at ), dataMetadata: sp.results ? createMetadataFromResults(sp.results) : null, publicPassword: null, dataSourceId, })); await tx.insert(metricFiles).values(metricRecords); // Insert asset permissions const assetPermissionRecords = metricRecords.map((record) => ({ identityId: userId, identityType: 'user' as const, assetId: record.id, assetType: 'metric_file' as const, role: 'owner' as const, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, createdBy: userId, updatedBy: userId, })); await tx.insert(assetPermissions).values(assetPermissionRecords); }); // Add successful files to output for (const sp of successfulProcessing) { files.push({ id: sp.metricFile.id, name: sp.metricFile.name, file_type: sp.metricFile.file_type, result_message: sp.metricFile.result_message || '', results: sp.metricFile.results || [], created_at: sp.metricFile.created_at, updated_at: sp.metricFile.updated_at, version_number: sp.metricFile.version_number, }); } } catch (error) { // Add all successful processing to failed if database operation fails for (const sp of successfulProcessing) { failedFiles.push({ name: sp.metricFile.name, error: `Failed to save to database: ${error instanceof Error ? error.message : 'Unknown error'}`, }); } } } // Track file associations if messageId is available if (messageId && files.length > 0) { await trackFileAssociations({ messageId, files: files.map((file) => ({ id: file.id, version: file.version_number, })), }); } const message = generateResultMessage(files, failedFiles); return { message, files, failed_files: failedFiles, }; }, { name: 'Create Metric Files' } ); export function createCreateMetricsExecute( context: CreateMetricsContext, state: CreateMetricsState ) { return wrapTraced( async (input: CreateMetricsInput): Promise => { const startTime = Date.now(); try { // Call the main function directly, passing state for metric IDs const result = await createMetricFiles(input, context, state); // Update state files with final results (IDs, versions, status) if (result && typeof result === 'object') { const typedResult = result as CreateMetricsOutput; // Ensure state.files is initialized for safe mutations below state.files = state.files ?? []; // Update successful files if (typedResult.files && Array.isArray(typedResult.files)) { typedResult.files.forEach((file) => { const stateFile = (state.files ?? []).find((f) => f.file_name === file.name); if (stateFile) { stateFile.id = file.id; stateFile.version_number = file.version_number; stateFile.status = 'completed'; } }); } // Update failed files if (typedResult.failed_files && Array.isArray(typedResult.failed_files)) { typedResult.failed_files.forEach((failedFile) => { const stateFile = (state.files ?? []).find((f) => f.file_name === failedFile.name); if (stateFile) { stateFile.status = 'failed'; // Add error to the state file if needed (not part of the current schema) } }); } // Update last entries if we have a messageId (no need for explicit entry IDs) if (context.messageId) { try { const finalStatus = typedResult.failed_files?.length ? 'failed' : 'completed'; const toolCallId = state.toolCallId || `tool-${Date.now()}`; // Update state for final status if (state.files) { state.files.forEach((f) => { if (!f.status || f.status === 'loading') { f.status = finalStatus === 'failed' ? 'failed' : 'completed'; } }); } const reasoningEntry = createCreateMetricsReasoningEntry(state, toolCallId); const rawLlmMessage = createCreateMetricsRawLlmMessageEntry(state, toolCallId); const rawLlmResultEntry = createRawToolResultEntry( toolCallId, CREATE_METRICS_TOOL_NAME, { files: state.files, } ); const updates: Parameters[0] = { messageId: context.messageId, }; if (reasoningEntry) { updates.reasoningMessages = [reasoningEntry]; } if (rawLlmMessage) { updates.rawLlmMessages = [rawLlmMessage, rawLlmResultEntry]; } if (reasoningEntry || rawLlmMessage) { await updateMessageEntries(updates); } console.info('[create-metrics] Updated last entries with final results', { messageId: context.messageId, successCount: typedResult.files?.length || 0, failedCount: typedResult.failed_files?.length || 0, }); } catch (error) { console.error('[create-metrics] Error updating final entries:', error); // Don't throw - return the result anyway } } } const executionTime = Date.now() - startTime; console.info('[create-metrics] Execution completed', { executionTime: `${executionTime}ms`, filesCreated: result?.files?.length || 0, filesFailed: result?.failed_files?.length || 0, }); return result as CreateMetricsOutput; } catch (error) { const executionTime = Date.now() - startTime; console.error('[create-metrics] Execution failed', { error, executionTime: `${executionTime}ms`, }); // Update last entries with failure status if possible if (context.messageId) { try { const toolCallId = state.toolCallId || `tool-${Date.now()}`; // Update state files to failed status if (state.files) { state.files.forEach((f) => { f.status = 'failed'; }); } const reasoningEntry = createCreateMetricsReasoningEntry(state, toolCallId); const rawLlmMessage = createCreateMetricsRawLlmMessageEntry(state, toolCallId); const rawLlmResultEntry = createRawToolResultEntry( toolCallId, CREATE_METRICS_TOOL_NAME, { files: state.files, } ); const updates: Parameters[0] = { messageId: context.messageId, }; if (reasoningEntry) { updates.reasoningMessages = [reasoningEntry]; } if (rawLlmMessage) { updates.rawLlmMessages = [rawLlmMessage, rawLlmResultEntry]; } if (reasoningEntry || rawLlmMessage) { await updateMessageEntries(updates); } } catch (updateError) { console.error('[create-metrics] Error updating entries on failure:', updateError); } } throw error; } }, { name: 'create-metrics-execute' } ); }