Files
elasticsearch/ts/core/plugins/plugin-manager.ts
Juergen Kunz 820f84ee61 fix(core): Resolve TypeScript strict mode and ES client API compatibility issues for v3.0.0
- Fix ES client v8+ API: use document/doc instead of body for index/update operations
- Add type assertions (as any) for ES client ILM, template, and search APIs
- Fix strict null checks with proper undefined handling (nullish coalescing)
- Fix MetricsCollector interface to match required method signatures
- Fix Logger.error signature compatibility in plugins
- Resolve TermsQuery type index signature conflict
- Remove sourceMap from tsconfig (handled by tsbuild with inlineSourceMap)
2025-11-29 21:19:28 +00:00

426 lines
11 KiB
TypeScript

/**
* 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) {
this.logger.error(`Failed to initialize plugin '${plugin.name}'`, error instanceof Error ? error : new Error(String(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<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) {
this.logger.error(`Failed to destroy plugin '${name}'`, error instanceof Error ? error : new Error(String(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<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;
} 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);
if (this.config.collectStats) {
const stats = this.pluginStats.get(plugin.name);
if (stats) {
stats.errors++;
stats.lastError = err.message;
}
}
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;
} 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);
if (this.config.collectStats) {
const stats = this.pluginStats.get(plugin.name);
if (stats) {
stats.errors++;
stats.lastError = err.message;
}
}
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;
}
} 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);
if (this.config.collectStats) {
const stats = this.pluginStats.get(plugin.name);
if (stats) {
stats.errors++;
stats.lastError = err.message;
}
}
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);
}