2025-11-29 18:32:00 +00:00
|
|
|
/**
|
|
|
|
|
* 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<PluginManagerConfig> = {
|
|
|
|
|
enabled: true,
|
|
|
|
|
maxHookDuration: 5000, // 5 seconds
|
|
|
|
|
continueOnError: true,
|
|
|
|
|
collectStats: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Plugin Manager
|
|
|
|
|
*/
|
|
|
|
|
export class PluginManager {
|
|
|
|
|
private plugins: Map<string, Plugin> = new Map();
|
|
|
|
|
private pluginStats: Map<string, PluginStats> = new Map();
|
|
|
|
|
private config: Required<PluginManagerConfig>;
|
|
|
|
|
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<void> {
|
|
|
|
|
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) {
|
2025-11-29 21:19:28 +00:00
|
|
|
this.logger.error(`Failed to initialize plugin '${plugin.name}'`, error instanceof Error ? error : new Error(String(error)));
|
2025-11-29 18:32:00 +00:00
|
|
|
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<void> {
|
|
|
|
|
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) {
|
2025-11-29 21:19:28 +00:00
|
|
|
this.logger.error(`Failed to destroy plugin '${name}'`, error instanceof Error ? error : new Error(String(error)));
|
2025-11-29 18:32:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<PluginContext | null> {
|
|
|
|
|
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;
|
2025-11-29 21:19:28 +00:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
|
|
|
this.logger.error(`Error in beforeRequest hook for plugin '${plugin.name}'`, err);
|
2025-11-29 18:32:00 +00:00
|
|
|
|
|
|
|
|
if (this.config.collectStats) {
|
|
|
|
|
const stats = this.pluginStats.get(plugin.name);
|
|
|
|
|
if (stats) {
|
|
|
|
|
stats.errors++;
|
2025-11-29 21:19:28 +00:00
|
|
|
stats.lastError = err.message;
|
2025-11-29 18:32:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.config.continueOnError) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return currentContext;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Execute afterResponse hooks
|
|
|
|
|
*/
|
|
|
|
|
async executeAfterResponse<T>(
|
|
|
|
|
context: PluginContext,
|
|
|
|
|
response: PluginResponse<T>
|
|
|
|
|
): Promise<PluginResponse<T>> {
|
|
|
|
|
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;
|
2025-11-29 21:19:28 +00:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
|
|
|
this.logger.error(`Error in afterResponse hook for plugin '${plugin.name}'`, err);
|
2025-11-29 18:32:00 +00:00
|
|
|
|
|
|
|
|
if (this.config.collectStats) {
|
|
|
|
|
const stats = this.pluginStats.get(plugin.name);
|
|
|
|
|
if (stats) {
|
|
|
|
|
stats.errors++;
|
2025-11-29 21:19:28 +00:00
|
|
|
stats.lastError = err.message;
|
2025-11-29 18:32:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.config.continueOnError) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return currentResponse;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Execute onError hooks
|
|
|
|
|
*/
|
|
|
|
|
async executeOnError(errorContext: PluginErrorContext): Promise<PluginResponse | null> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
2025-11-29 21:19:28 +00:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
|
|
|
this.logger.error(`Error in onError hook for plugin '${plugin.name}'`, err);
|
2025-11-29 18:32:00 +00:00
|
|
|
|
|
|
|
|
if (this.config.collectStats) {
|
|
|
|
|
const stats = this.pluginStats.get(plugin.name);
|
|
|
|
|
if (stats) {
|
|
|
|
|
stats.errors++;
|
2025-11-29 21:19:28 +00:00
|
|
|
stats.lastError = err.message;
|
2025-11-29 18:32:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.config.continueOnError) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get plugin statistics
|
|
|
|
|
*/
|
|
|
|
|
getStats(): Map<string, PluginStats> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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<T>(
|
|
|
|
|
fn: () => Promise<T> | T,
|
|
|
|
|
timeoutMs: number,
|
|
|
|
|
description: string
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
return Promise.race([
|
|
|
|
|
Promise.resolve(fn()),
|
|
|
|
|
new Promise<T>((_, 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);
|
|
|
|
|
}
|