/** * Plugin Manager * * Orchestrates plugin execution through request/response lifecycle */ import type { Client } from '@elastic/elasticsearch'; import { Logger, defaultLogger } from '../observability/logger.js'; import { MetricsCollector, defaultMetricsCollector } from '../observability/metrics.js'; import type { Plugin, PluginContext, PluginResponse, PluginErrorContext, PluginStats, PluginManagerConfig, } from './types.js'; /** * Default configuration */ const DEFAULT_CONFIG: Required = { enabled: true, maxHookDuration: 5000, // 5 seconds continueOnError: true, collectStats: true, }; /** * Plugin Manager */ export class PluginManager { private plugins: Map = new Map(); private pluginStats: Map = new Map(); private config: Required; private logger: Logger; private metrics: MetricsCollector; private client?: Client; constructor(config: PluginManagerConfig = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; this.logger = defaultLogger; this.metrics = defaultMetricsCollector; } /** * Set the Elasticsearch client */ setClient(client: Client): void { this.client = client; } /** * Register a plugin */ async register(plugin: Plugin): Promise { if (this.plugins.has(plugin.name)) { throw new Error(`Plugin '${plugin.name}' is already registered`); } // Initialize plugin if (plugin.initialize && this.client) { try { await plugin.initialize(this.client, plugin.config || {}); } catch (error) { this.logger.error(`Failed to initialize plugin '${plugin.name}'`, { error }); throw error; } } this.plugins.set(plugin.name, plugin); // Initialize stats if (this.config.collectStats) { this.pluginStats.set(plugin.name, { name: plugin.name, beforeRequestCalls: 0, afterResponseCalls: 0, onErrorCalls: 0, avgBeforeRequestDuration: 0, avgAfterResponseDuration: 0, avgOnErrorDuration: 0, errors: 0, }); } this.logger.info(`Plugin '${plugin.name}' registered`, { version: plugin.version, priority: plugin.priority, }); this.metrics.recordCounter('plugins.registered', 1, { plugin: plugin.name, }); } /** * Unregister a plugin */ async unregister(name: string): Promise { const plugin = this.plugins.get(name); if (!plugin) { throw new Error(`Plugin '${name}' is not registered`); } // Cleanup plugin if (plugin.destroy) { try { await plugin.destroy(); } catch (error) { this.logger.error(`Failed to destroy plugin '${name}'`, { error }); } } this.plugins.delete(name); this.pluginStats.delete(name); this.logger.info(`Plugin '${name}' unregistered`); this.metrics.recordCounter('plugins.unregistered', 1, { plugin: name, }); } /** * Get a registered plugin */ getPlugin(name: string): Plugin | undefined { return this.plugins.get(name); } /** * Get all registered plugins */ getPlugins(): Plugin[] { return Array.from(this.plugins.values()); } /** * Get plugins sorted by priority */ private getSortedPlugins(): Plugin[] { return Array.from(this.plugins.values()).sort( (a, b) => (a.priority ?? 100) - (b.priority ?? 100) ); } /** * Execute beforeRequest hooks */ async executeBeforeRequest(context: PluginContext): Promise { if (!this.config.enabled) { return context; } let currentContext = context; for (const plugin of this.getSortedPlugins()) { if (!plugin.beforeRequest) continue; const startTime = Date.now(); try { const result = await this.executeWithTimeout( () => plugin.beforeRequest!(currentContext), this.config.maxHookDuration, `beforeRequest hook for plugin '${plugin.name}'` ); const duration = Date.now() - startTime; // Update stats if (this.config.collectStats) { this.updateHookStats(plugin.name, 'beforeRequest', duration); } this.metrics.recordHistogram('plugins.before_request.duration', duration, { plugin: plugin.name, }); // Handle cancellation if (result === null) { this.logger.debug(`Request cancelled by plugin '${plugin.name}'`); return null; } currentContext = result; } catch (error: any) { this.logger.error(`Error in beforeRequest hook for plugin '${plugin.name}'`, { error, }); if (this.config.collectStats) { const stats = this.pluginStats.get(plugin.name); if (stats) { stats.errors++; stats.lastError = error.message; } } if (!this.config.continueOnError) { throw error; } } } return currentContext; } /** * Execute afterResponse hooks */ async executeAfterResponse( context: PluginContext, response: PluginResponse ): Promise> { if (!this.config.enabled) { return response; } let currentResponse = response; for (const plugin of this.getSortedPlugins()) { if (!plugin.afterResponse) continue; const startTime = Date.now(); try { const result = await this.executeWithTimeout( () => plugin.afterResponse!(context, currentResponse), this.config.maxHookDuration, `afterResponse hook for plugin '${plugin.name}'` ); const duration = Date.now() - startTime; // Update stats if (this.config.collectStats) { this.updateHookStats(plugin.name, 'afterResponse', duration); } this.metrics.recordHistogram('plugins.after_response.duration', duration, { plugin: plugin.name, }); currentResponse = result; } catch (error: any) { this.logger.error(`Error in afterResponse hook for plugin '${plugin.name}'`, { error, }); if (this.config.collectStats) { const stats = this.pluginStats.get(plugin.name); if (stats) { stats.errors++; stats.lastError = error.message; } } if (!this.config.continueOnError) { throw error; } } } return currentResponse; } /** * Execute onError hooks */ async executeOnError(errorContext: PluginErrorContext): Promise { if (!this.config.enabled) { return null; } for (const plugin of this.getSortedPlugins()) { if (!plugin.onError) continue; const startTime = Date.now(); try { const result = await this.executeWithTimeout( () => plugin.onError!(errorContext), this.config.maxHookDuration, `onError hook for plugin '${plugin.name}'` ); const duration = Date.now() - startTime; // Update stats if (this.config.collectStats) { this.updateHookStats(plugin.name, 'onError', duration); } this.metrics.recordHistogram('plugins.on_error.duration', duration, { plugin: plugin.name, }); // If plugin handled the error and returned a response, use it if (result !== null) { this.logger.debug(`Error handled by plugin '${plugin.name}'`); return result; } } catch (error: any) { this.logger.error(`Error in onError hook for plugin '${plugin.name}'`, { error }); if (this.config.collectStats) { const stats = this.pluginStats.get(plugin.name); if (stats) { stats.errors++; stats.lastError = error.message; } } if (!this.config.continueOnError) { throw error; } } } return null; } /** * Get plugin statistics */ getStats(): Map { return new Map(this.pluginStats); } /** * Clear plugin statistics */ clearStats(): void { for (const stats of this.pluginStats.values()) { stats.beforeRequestCalls = 0; stats.afterResponseCalls = 0; stats.onErrorCalls = 0; stats.avgBeforeRequestDuration = 0; stats.avgAfterResponseDuration = 0; stats.avgOnErrorDuration = 0; stats.errors = 0; stats.lastError = undefined; } } /** * Destroy all plugins */ async destroy(): Promise { const pluginNames = Array.from(this.plugins.keys()); for (const name of pluginNames) { await this.unregister(name); } this.pluginStats.clear(); } // ============================================================================ // Private Methods // ============================================================================ /** * Execute a function with timeout */ private async executeWithTimeout( fn: () => Promise | T, timeoutMs: number, description: string ): Promise { return Promise.race([ Promise.resolve(fn()), new Promise((_, reject) => setTimeout( () => reject(new Error(`Timeout executing ${description} (${timeoutMs}ms)`)), timeoutMs ) ), ]); } /** * Update hook statistics */ private updateHookStats( pluginName: string, hook: 'beforeRequest' | 'afterResponse' | 'onError', duration: number ): void { const stats = this.pluginStats.get(pluginName); if (!stats) return; switch (hook) { case 'beforeRequest': stats.beforeRequestCalls++; stats.avgBeforeRequestDuration = (stats.avgBeforeRequestDuration * (stats.beforeRequestCalls - 1) + duration) / stats.beforeRequestCalls; break; case 'afterResponse': stats.afterResponseCalls++; stats.avgAfterResponseDuration = (stats.avgAfterResponseDuration * (stats.afterResponseCalls - 1) + duration) / stats.afterResponseCalls; break; case 'onError': stats.onErrorCalls++; stats.avgOnErrorDuration = (stats.avgOnErrorDuration * (stats.onErrorCalls - 1) + duration) / stats.onErrorCalls; break; } } } /** * Create a plugin manager */ export function createPluginManager(config?: PluginManagerConfig): PluginManager { return new PluginManager(config); }