BREAKING CHANGE(core): Refactor to v3: introduce modular core/domain architecture, plugin system, observability and strict TypeScript configuration; remove legacy classes
This commit is contained in:
407
ts/core/config/configuration-builder.ts
Normal file
407
ts/core/config/configuration-builder.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import type {
|
||||
ElasticsearchConfig,
|
||||
AuthConfig,
|
||||
SecretProvider,
|
||||
} from './types.js';
|
||||
import {
|
||||
ConfigValidationError,
|
||||
EnvironmentSecretProvider,
|
||||
} from './types.js';
|
||||
import { LogLevel } from '../observability/logger.js';
|
||||
|
||||
/**
|
||||
* Configuration builder for fluent Elasticsearch configuration
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = new ConfigurationBuilder()
|
||||
* .nodes(['http://localhost:9200', 'http://localhost:9201'])
|
||||
* .auth({ type: 'basic', username: 'elastic', password: 'changeme' })
|
||||
* .timeout(30000)
|
||||
* .retries(3)
|
||||
* .build();
|
||||
* ```
|
||||
*/
|
||||
export class ConfigurationBuilder {
|
||||
private config: Partial<ElasticsearchConfig> = {};
|
||||
private secretProvider?: SecretProvider;
|
||||
|
||||
/**
|
||||
* Set Elasticsearch node(s)
|
||||
*/
|
||||
nodes(nodes: string | string[]): this {
|
||||
this.config.nodes = nodes;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication configuration
|
||||
*/
|
||||
auth(auth: AuthConfig): this {
|
||||
this.config.auth = auth;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set basic authentication
|
||||
*/
|
||||
basicAuth(username: string, password: string): this {
|
||||
this.config.auth = { type: 'basic', username, password };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set API key authentication
|
||||
*/
|
||||
apiKeyAuth(apiKey: string): this {
|
||||
this.config.auth = { type: 'apiKey', apiKey };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set bearer token authentication
|
||||
*/
|
||||
bearerAuth(token: string): this {
|
||||
this.config.auth = { type: 'bearer', token };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cloud ID authentication
|
||||
*/
|
||||
cloudAuth(id: string, options?: { username?: string; password?: string; apiKey?: string }): this {
|
||||
this.config.auth = {
|
||||
type: 'cloud',
|
||||
id,
|
||||
...options,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set request timeout
|
||||
*/
|
||||
timeout(timeoutMs: number): this {
|
||||
if (!this.config.request) {
|
||||
this.config.request = {};
|
||||
}
|
||||
this.config.request.timeout = timeoutMs;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set maximum retries
|
||||
*/
|
||||
retries(maxRetries: number): this {
|
||||
if (!this.config.request) {
|
||||
this.config.request = {};
|
||||
}
|
||||
this.config.request.maxRetries = maxRetries;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable compression
|
||||
*/
|
||||
compression(enabled: boolean = true): this {
|
||||
if (!this.config.request) {
|
||||
this.config.request = {};
|
||||
}
|
||||
this.config.request.compression = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set connection pool size
|
||||
*/
|
||||
poolSize(max: number, min?: number): this {
|
||||
if (!this.config.pool) {
|
||||
this.config.pool = {};
|
||||
}
|
||||
this.config.pool.maxConnections = max;
|
||||
if (min !== undefined) {
|
||||
this.config.pool.minIdleConnections = min;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable node discovery/sniffing
|
||||
*/
|
||||
discovery(enabled: boolean = true, options?: { interval?: number }): this {
|
||||
this.config.discovery = {
|
||||
enabled,
|
||||
...options,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set log level
|
||||
*/
|
||||
logLevel(level: LogLevel): this {
|
||||
if (!this.config.logging) {
|
||||
this.config.logging = {};
|
||||
}
|
||||
this.config.logging.level = level;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable request/response logging
|
||||
*/
|
||||
enableRequestLogging(enabled: boolean = true): this {
|
||||
if (!this.config.logging) {
|
||||
this.config.logging = {};
|
||||
}
|
||||
this.config.logging.enableRequestLogging = enabled;
|
||||
this.config.logging.enableResponseLogging = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable metrics collection
|
||||
*/
|
||||
enableMetrics(enabled: boolean = true, prefix?: string): this {
|
||||
this.config.metrics = {
|
||||
enabled,
|
||||
...(prefix && { prefix }),
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable tracing
|
||||
*/
|
||||
enableTracing(enabled: boolean = true, options?: { serviceName?: string; serviceVersion?: string }): this {
|
||||
this.config.tracing = {
|
||||
enabled,
|
||||
...options,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set proxy URL
|
||||
*/
|
||||
proxy(proxyUrl: string): this {
|
||||
this.config.proxy = proxyUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from environment variables
|
||||
*
|
||||
* Supported environment variables:
|
||||
* - ELASTICSEARCH_URL or ELASTICSEARCH_NODES (comma-separated)
|
||||
* - ELASTICSEARCH_USERNAME
|
||||
* - ELASTICSEARCH_PASSWORD
|
||||
* - ELASTICSEARCH_API_KEY
|
||||
* - ELASTICSEARCH_CLOUD_ID
|
||||
* - ELASTICSEARCH_TIMEOUT
|
||||
* - ELASTICSEARCH_MAX_RETRIES
|
||||
* - ELASTICSEARCH_LOG_LEVEL
|
||||
* - ELASTICSEARCH_PROXY
|
||||
*/
|
||||
fromEnv(): this {
|
||||
// Nodes
|
||||
const url = process.env.ELASTICSEARCH_URL;
|
||||
const nodes = process.env.ELASTICSEARCH_NODES;
|
||||
if (url) {
|
||||
this.config.nodes = url;
|
||||
} else if (nodes) {
|
||||
this.config.nodes = nodes.split(',').map((n) => n.trim());
|
||||
}
|
||||
|
||||
// Authentication
|
||||
const apiKey = process.env.ELASTICSEARCH_API_KEY;
|
||||
const username = process.env.ELASTICSEARCH_USERNAME;
|
||||
const password = process.env.ELASTICSEARCH_PASSWORD;
|
||||
const cloudId = process.env.ELASTICSEARCH_CLOUD_ID;
|
||||
|
||||
if (apiKey) {
|
||||
this.apiKeyAuth(apiKey);
|
||||
} else if (cloudId) {
|
||||
this.cloudAuth(cloudId, { username, password, apiKey });
|
||||
} else if (username && password) {
|
||||
this.basicAuth(username, password);
|
||||
}
|
||||
|
||||
// Request settings
|
||||
const timeout = process.env.ELASTICSEARCH_TIMEOUT;
|
||||
if (timeout) {
|
||||
this.timeout(parseInt(timeout, 10));
|
||||
}
|
||||
|
||||
const maxRetries = process.env.ELASTICSEARCH_MAX_RETRIES;
|
||||
if (maxRetries) {
|
||||
this.retries(parseInt(maxRetries, 10));
|
||||
}
|
||||
|
||||
// Logging
|
||||
const logLevel = process.env.ELASTICSEARCH_LOG_LEVEL as LogLevel;
|
||||
if (logLevel && Object.values(LogLevel).includes(logLevel)) {
|
||||
this.logLevel(logLevel);
|
||||
}
|
||||
|
||||
// Proxy
|
||||
const proxy = process.env.ELASTICSEARCH_PROXY;
|
||||
if (proxy) {
|
||||
this.proxy(proxy);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from JSON file
|
||||
*/
|
||||
fromFile(filePath: string): this {
|
||||
try {
|
||||
const fileContent = readFileSync(filePath, 'utf-8');
|
||||
const fileConfig = JSON.parse(fileContent) as Partial<ElasticsearchConfig>;
|
||||
this.fromObject(fileConfig);
|
||||
} catch (error) {
|
||||
throw new ConfigValidationError(
|
||||
'file',
|
||||
`Failed to load configuration from file: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from object
|
||||
*/
|
||||
fromObject(configObject: Partial<ElasticsearchConfig>): this {
|
||||
// Merge the object into current config
|
||||
this.config = {
|
||||
...this.config,
|
||||
...configObject,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set secret provider for fetching credentials
|
||||
*/
|
||||
withSecrets(provider: SecretProvider): this {
|
||||
this.secretProvider = provider;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve secrets using the configured secret provider
|
||||
*/
|
||||
private async resolveSecrets(): Promise<void> {
|
||||
if (!this.secretProvider) {
|
||||
this.secretProvider = new EnvironmentSecretProvider();
|
||||
}
|
||||
|
||||
// Resolve authentication secrets
|
||||
if (this.config.auth) {
|
||||
switch (this.config.auth.type) {
|
||||
case 'basic': {
|
||||
const usernameSecret = await this.secretProvider.getSecret('ELASTICSEARCH_USERNAME');
|
||||
const passwordSecret = await this.secretProvider.getSecret('ELASTICSEARCH_PASSWORD');
|
||||
if (usernameSecret) this.config.auth.username = usernameSecret;
|
||||
if (passwordSecret) this.config.auth.password = passwordSecret;
|
||||
break;
|
||||
}
|
||||
case 'apiKey': {
|
||||
const apiKeySecret = await this.secretProvider.getSecret('ELASTICSEARCH_API_KEY');
|
||||
if (apiKeySecret) this.config.auth.apiKey = apiKeySecret;
|
||||
break;
|
||||
}
|
||||
case 'bearer': {
|
||||
const tokenSecret = await this.secretProvider.getSecret('ELASTICSEARCH_BEARER_TOKEN');
|
||||
if (tokenSecret) this.config.auth.token = tokenSecret;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the configuration
|
||||
*/
|
||||
private validate(config: Partial<ElasticsearchConfig>): ElasticsearchConfig {
|
||||
// Required fields
|
||||
if (!config.nodes) {
|
||||
throw new ConfigValidationError('nodes', 'Elasticsearch node(s) must be specified');
|
||||
}
|
||||
|
||||
// Normalize nodes to array
|
||||
const nodes = Array.isArray(config.nodes) ? config.nodes : [config.nodes];
|
||||
|
||||
if (nodes.length === 0) {
|
||||
throw new ConfigValidationError('nodes', 'At least one Elasticsearch node must be specified');
|
||||
}
|
||||
|
||||
// Validate node URLs
|
||||
for (const node of nodes) {
|
||||
try {
|
||||
new URL(node);
|
||||
} catch {
|
||||
throw new ConfigValidationError(
|
||||
'nodes',
|
||||
`Invalid node URL: ${node}. Must be a valid HTTP(S) URL`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate timeout
|
||||
if (config.request?.timeout !== undefined && config.request.timeout <= 0) {
|
||||
throw new ConfigValidationError('request.timeout', 'Timeout must be a positive number');
|
||||
}
|
||||
|
||||
// Validate retries
|
||||
if (config.request?.maxRetries !== undefined && config.request.maxRetries < 0) {
|
||||
throw new ConfigValidationError('request.maxRetries', 'Max retries cannot be negative');
|
||||
}
|
||||
|
||||
// Validate pool size
|
||||
if (config.pool?.maxConnections !== undefined && config.pool.maxConnections <= 0) {
|
||||
throw new ConfigValidationError('pool.maxConnections', 'Max connections must be positive');
|
||||
}
|
||||
|
||||
if (
|
||||
config.pool?.minIdleConnections !== undefined &&
|
||||
config.pool?.maxConnections !== undefined &&
|
||||
config.pool.minIdleConnections > config.pool.maxConnections
|
||||
) {
|
||||
throw new ConfigValidationError(
|
||||
'pool.minIdleConnections',
|
||||
'Min idle connections cannot exceed max connections'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
...config,
|
||||
} as ElasticsearchConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and validate the configuration
|
||||
*/
|
||||
async buildAsync(): Promise<ElasticsearchConfig> {
|
||||
await this.resolveSecrets();
|
||||
return this.validate(this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and validate the configuration (synchronous)
|
||||
*/
|
||||
build(): ElasticsearchConfig {
|
||||
return this.validate(this.config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new configuration builder
|
||||
*/
|
||||
export function createConfig(): ConfigurationBuilder {
|
||||
return new ConfigurationBuilder();
|
||||
}
|
||||
15
ts/core/config/index.ts
Normal file
15
ts/core/config/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Configuration management for Elasticsearch client
|
||||
*
|
||||
* This module provides:
|
||||
* - Fluent configuration builder
|
||||
* - Environment variable support
|
||||
* - File-based configuration
|
||||
* - Secret provider integration
|
||||
* - Configuration validation
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './types.js';
|
||||
export * from './configuration-builder.js';
|
||||
232
ts/core/config/types.ts
Normal file
232
ts/core/config/types.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { RetryConfig } from '../errors/types.js';
|
||||
import { LogLevel } from '../observability/logger.js';
|
||||
|
||||
/**
|
||||
* Authentication configuration
|
||||
*/
|
||||
export type AuthConfig =
|
||||
| {
|
||||
type: 'basic';
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
| {
|
||||
type: 'apiKey';
|
||||
apiKey: string;
|
||||
}
|
||||
| {
|
||||
type: 'bearer';
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
type: 'cloud';
|
||||
id: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* TLS/SSL configuration
|
||||
*/
|
||||
export interface TLSConfig {
|
||||
/** Reject unauthorized certificates */
|
||||
rejectUnauthorized?: boolean;
|
||||
|
||||
/** CA certificate(s) */
|
||||
ca?: string | string[] | Buffer | Buffer[];
|
||||
|
||||
/** Client certificate */
|
||||
cert?: string | Buffer;
|
||||
|
||||
/** Client private key */
|
||||
key?: string | Buffer;
|
||||
|
||||
/** Passphrase for client key */
|
||||
passphrase?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection pool configuration
|
||||
*/
|
||||
export interface ConnectionPoolConfig {
|
||||
/** Maximum number of connections */
|
||||
maxConnections?: number;
|
||||
|
||||
/** Minimum number of idle connections to maintain */
|
||||
minIdleConnections?: number;
|
||||
|
||||
/** Maximum time (ms) a connection can be idle before being closed */
|
||||
maxIdleTime?: number;
|
||||
|
||||
/** Maximum time (ms) to wait for a connection from the pool */
|
||||
acquireTimeout?: number;
|
||||
|
||||
/** Enable connection pool metrics */
|
||||
enableMetrics?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request configuration
|
||||
*/
|
||||
export interface RequestConfig {
|
||||
/** Request timeout in milliseconds */
|
||||
timeout?: number;
|
||||
|
||||
/** Maximum number of retries */
|
||||
maxRetries?: number;
|
||||
|
||||
/** Retry delay strategy */
|
||||
retryDelay?: 'exponential' | 'linear' | 'fixed';
|
||||
|
||||
/** Compression for request bodies */
|
||||
compression?: boolean;
|
||||
|
||||
/** Maximum request body size */
|
||||
maxBodySize?: number;
|
||||
|
||||
/** Request headers to include in all requests */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery configuration (node sniffing)
|
||||
*/
|
||||
export interface DiscoveryConfig {
|
||||
/** Enable node discovery/sniffing */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Interval (ms) between discovery attempts */
|
||||
interval?: number;
|
||||
|
||||
/** Whether to sniff on connection failure */
|
||||
sniffOnConnectionFault?: boolean;
|
||||
|
||||
/** Whether to sniff on start */
|
||||
sniffOnStart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Elasticsearch configuration
|
||||
*/
|
||||
export interface ElasticsearchConfig {
|
||||
/** Elasticsearch node(s) */
|
||||
nodes: string | string[];
|
||||
|
||||
/** Authentication configuration */
|
||||
auth?: AuthConfig;
|
||||
|
||||
/** TLS/SSL configuration */
|
||||
tls?: TLSConfig;
|
||||
|
||||
/** Connection pool configuration */
|
||||
pool?: ConnectionPoolConfig;
|
||||
|
||||
/** Request configuration */
|
||||
request?: RequestConfig;
|
||||
|
||||
/** Node discovery configuration */
|
||||
discovery?: DiscoveryConfig;
|
||||
|
||||
/** Retry configuration */
|
||||
retry?: Partial<RetryConfig>;
|
||||
|
||||
/** Logging configuration */
|
||||
logging?: {
|
||||
level?: LogLevel;
|
||||
enableRequestLogging?: boolean;
|
||||
enableResponseLogging?: boolean;
|
||||
};
|
||||
|
||||
/** Metrics collection */
|
||||
metrics?: {
|
||||
enabled?: boolean;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
/** Tracing configuration */
|
||||
tracing?: {
|
||||
enabled?: boolean;
|
||||
serviceName?: string;
|
||||
serviceVersion?: string;
|
||||
};
|
||||
|
||||
/** Proxy configuration */
|
||||
proxy?: string;
|
||||
|
||||
/** Custom agent for HTTP requests */
|
||||
agent?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration validation error
|
||||
*/
|
||||
export class ConfigValidationError extends Error {
|
||||
constructor(
|
||||
public readonly field: string,
|
||||
public readonly reason: string
|
||||
) {
|
||||
super(`Configuration validation failed for field "${field}": ${reason}`);
|
||||
this.name = 'ConfigValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret provider interface for fetching secrets from external sources
|
||||
*/
|
||||
export interface SecretProvider {
|
||||
/**
|
||||
* Get a secret by key
|
||||
*/
|
||||
getSecret(key: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get multiple secrets by keys
|
||||
*/
|
||||
getSecrets(keys: string[]): Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment variable secret provider
|
||||
*/
|
||||
export class EnvironmentSecretProvider implements SecretProvider {
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
return process.env[key] || null;
|
||||
}
|
||||
|
||||
async getSecrets(keys: string[]): Promise<Record<string, string>> {
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const key of keys) {
|
||||
const value = process.env[key];
|
||||
if (value) {
|
||||
secrets[key] = value;
|
||||
}
|
||||
}
|
||||
return secrets;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory secret provider (for testing)
|
||||
*/
|
||||
export class InMemorySecretProvider implements SecretProvider {
|
||||
constructor(private secrets: Record<string, string> = {}) {}
|
||||
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
return this.secrets[key] || null;
|
||||
}
|
||||
|
||||
async getSecrets(keys: string[]): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const key of keys) {
|
||||
if (this.secrets[key]) {
|
||||
result[key] = this.secrets[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
setSecret(key: string, value: string): void {
|
||||
this.secrets[key] = value;
|
||||
}
|
||||
}
|
||||
306
ts/core/connection/circuit-breaker.ts
Normal file
306
ts/core/connection/circuit-breaker.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Circuit breaker states
|
||||
*/
|
||||
export enum CircuitState {
|
||||
/** Circuit is closed, requests flow normally */
|
||||
CLOSED = 'closed',
|
||||
|
||||
/** Circuit is open, requests are rejected immediately */
|
||||
OPEN = 'open',
|
||||
|
||||
/** Circuit is half-open, testing if service recovered */
|
||||
HALF_OPEN = 'half_open',
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker configuration
|
||||
*/
|
||||
export interface CircuitBreakerConfig {
|
||||
/** Number of failures before opening circuit */
|
||||
failureThreshold: number;
|
||||
|
||||
/** Number of successes in half-open state before closing */
|
||||
successThreshold: number;
|
||||
|
||||
/** Time in milliseconds circuit stays open before attempting half-open */
|
||||
timeout: number;
|
||||
|
||||
/** Time window in milliseconds for counting failures */
|
||||
rollingWindow: number;
|
||||
|
||||
/** Whether circuit breaker is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default circuit breaker configuration
|
||||
*/
|
||||
export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = {
|
||||
failureThreshold: 5,
|
||||
successThreshold: 2,
|
||||
timeout: 60000, // 1 minute
|
||||
rollingWindow: 10000, // 10 seconds
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Circuit breaker error thrown when circuit is open
|
||||
*/
|
||||
export class CircuitBreakerOpenError extends Error {
|
||||
constructor(
|
||||
public readonly circuitName: string,
|
||||
public readonly nextAttemptTime: Date
|
||||
) {
|
||||
super(
|
||||
`Circuit breaker "${circuitName}" is OPEN. Next attempt at ${nextAttemptTime.toISOString()}`
|
||||
);
|
||||
this.name = 'CircuitBreakerOpenError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure record for tracking
|
||||
*/
|
||||
interface FailureRecord {
|
||||
timestamp: number;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker for preventing cascading failures
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const breaker = new CircuitBreaker('elasticsearch', {
|
||||
* failureThreshold: 5,
|
||||
* timeout: 60000,
|
||||
* });
|
||||
*
|
||||
* try {
|
||||
* const result = await breaker.execute(async () => {
|
||||
* return await someElasticsearchOperation();
|
||||
* });
|
||||
* } catch (error) {
|
||||
* if (error instanceof CircuitBreakerOpenError) {
|
||||
* // Circuit is open, handle gracefully
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CircuitBreaker {
|
||||
private config: CircuitBreakerConfig;
|
||||
private state: CircuitState = CircuitState.CLOSED;
|
||||
private failures: FailureRecord[] = [];
|
||||
private successCount = 0;
|
||||
private openedAt?: number;
|
||||
private nextAttemptTime?: number;
|
||||
|
||||
constructor(
|
||||
private name: string,
|
||||
config: Partial<CircuitBreakerConfig> = {}
|
||||
) {
|
||||
this.config = { ...DEFAULT_CIRCUIT_BREAKER_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation through the circuit breaker
|
||||
*/
|
||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||
if (!this.config.enabled) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
// Check circuit state
|
||||
this.updateState();
|
||||
|
||||
if (this.state === CircuitState.OPEN) {
|
||||
const nextAttempt = this.nextAttemptTime
|
||||
? new Date(this.nextAttemptTime)
|
||||
: new Date(Date.now() + this.config.timeout);
|
||||
throw new CircuitBreakerOpenError(this.name, nextAttempt);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
this.onSuccess();
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.onFailure(error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful operation
|
||||
*/
|
||||
private onSuccess(): void {
|
||||
this.removeOldFailures();
|
||||
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
this.successCount++;
|
||||
|
||||
if (this.successCount >= this.config.successThreshold) {
|
||||
this.transitionTo(CircuitState.CLOSED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed operation
|
||||
*/
|
||||
private onFailure(error: Error): void {
|
||||
this.failures.push({
|
||||
timestamp: Date.now(),
|
||||
error,
|
||||
});
|
||||
|
||||
this.removeOldFailures();
|
||||
|
||||
if (this.state === CircuitState.HALF_OPEN) {
|
||||
// Any failure in half-open state opens the circuit immediately
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
} else if (this.state === CircuitState.CLOSED) {
|
||||
// Check if we've exceeded failure threshold
|
||||
if (this.failures.length >= this.config.failureThreshold) {
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update circuit state based on time
|
||||
*/
|
||||
private updateState(): void {
|
||||
if (this.state === CircuitState.OPEN && this.nextAttemptTime) {
|
||||
if (Date.now() >= this.nextAttemptTime) {
|
||||
this.transitionTo(CircuitState.HALF_OPEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to a new state
|
||||
*/
|
||||
private transitionTo(newState: CircuitState): void {
|
||||
const previousState = this.state;
|
||||
this.state = newState;
|
||||
|
||||
switch (newState) {
|
||||
case CircuitState.OPEN:
|
||||
this.openedAt = Date.now();
|
||||
this.nextAttemptTime = Date.now() + this.config.timeout;
|
||||
this.successCount = 0;
|
||||
break;
|
||||
|
||||
case CircuitState.HALF_OPEN:
|
||||
this.successCount = 0;
|
||||
break;
|
||||
|
||||
case CircuitState.CLOSED:
|
||||
this.failures = [];
|
||||
this.successCount = 0;
|
||||
this.openedAt = undefined;
|
||||
this.nextAttemptTime = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
if (previousState !== newState) {
|
||||
this.onStateChange(previousState, newState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove failures outside the rolling window
|
||||
*/
|
||||
private removeOldFailures(): void {
|
||||
const cutoff = Date.now() - this.config.rollingWindow;
|
||||
this.failures = this.failures.filter((f) => f.timestamp >= cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback when state changes (can be overridden)
|
||||
*/
|
||||
protected onStateChange(from: CircuitState, to: CircuitState): void {
|
||||
// Override in subclass or use getState() to monitor
|
||||
console.log(`Circuit breaker "${this.name}" transitioned from ${from} to ${to}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current circuit state
|
||||
*/
|
||||
getState(): CircuitState {
|
||||
this.updateState();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit statistics
|
||||
*/
|
||||
getStats(): {
|
||||
state: CircuitState;
|
||||
failureCount: number;
|
||||
successCount: number;
|
||||
openedAt?: Date;
|
||||
nextAttemptTime?: Date;
|
||||
} {
|
||||
this.removeOldFailures();
|
||||
this.updateState();
|
||||
|
||||
return {
|
||||
state: this.state,
|
||||
failureCount: this.failures.length,
|
||||
successCount: this.successCount,
|
||||
...(this.openedAt && { openedAt: new Date(this.openedAt) }),
|
||||
...(this.nextAttemptTime && { nextAttemptTime: new Date(this.nextAttemptTime) }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually open the circuit
|
||||
*/
|
||||
open(): void {
|
||||
this.transitionTo(CircuitState.OPEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually close the circuit
|
||||
*/
|
||||
close(): void {
|
||||
this.transitionTo(CircuitState.CLOSED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the circuit breaker
|
||||
*/
|
||||
reset(): void {
|
||||
this.failures = [];
|
||||
this.successCount = 0;
|
||||
this.openedAt = undefined;
|
||||
this.nextAttemptTime = undefined;
|
||||
this.state = CircuitState.CLOSED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is open
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
this.updateState();
|
||||
return this.state === CircuitState.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is closed
|
||||
*/
|
||||
isClosed(): boolean {
|
||||
this.updateState();
|
||||
return this.state === CircuitState.CLOSED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is half-open
|
||||
*/
|
||||
isHalfOpen(): boolean {
|
||||
this.updateState();
|
||||
return this.state === CircuitState.HALF_OPEN;
|
||||
}
|
||||
}
|
||||
358
ts/core/connection/connection-manager.ts
Normal file
358
ts/core/connection/connection-manager.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { Client as ElasticClient } from '@elastic/elasticsearch';
|
||||
import { ElasticsearchConfig } from '../config/types.js';
|
||||
import { HealthChecker, HealthCheckResult, HealthStatus } from './health-check.js';
|
||||
import { CircuitBreaker } from './circuit-breaker.js';
|
||||
import { Logger, defaultLogger } from '../observability/logger.js';
|
||||
import { MetricsCollector, defaultMetricsCollector } from '../observability/metrics.js';
|
||||
import { ConnectionError, ClusterUnavailableError } from '../errors/elasticsearch-error.js';
|
||||
|
||||
/**
|
||||
* Connection manager configuration
|
||||
*/
|
||||
export interface ConnectionManagerConfig extends ElasticsearchConfig {
|
||||
/** Enable health checks */
|
||||
enableHealthCheck?: boolean;
|
||||
|
||||
/** Enable circuit breaker */
|
||||
enableCircuitBreaker?: boolean;
|
||||
|
||||
/** Logger instance */
|
||||
logger?: Logger;
|
||||
|
||||
/** Metrics collector */
|
||||
metricsCollector?: MetricsCollector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection manager for Elasticsearch client
|
||||
*
|
||||
* Provides:
|
||||
* - Singleton client instance
|
||||
* - Connection pooling
|
||||
* - Health monitoring
|
||||
* - Circuit breaker pattern
|
||||
* - Automatic reconnection
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const manager = ElasticsearchConnectionManager.getInstance({
|
||||
* nodes: ['http://localhost:9200'],
|
||||
* auth: { type: 'basic', username: 'elastic', password: 'changeme' }
|
||||
* });
|
||||
*
|
||||
* const client = manager.getClient();
|
||||
* await client.search({ index: 'my-index', query: { match_all: {} } });
|
||||
* ```
|
||||
*/
|
||||
export class ElasticsearchConnectionManager {
|
||||
private static instance: ElasticsearchConnectionManager | null = null;
|
||||
|
||||
private client: ElasticClient;
|
||||
private healthChecker: HealthChecker;
|
||||
private circuitBreaker: CircuitBreaker;
|
||||
private logger: Logger;
|
||||
private metrics: MetricsCollector;
|
||||
private config: ConnectionManagerConfig;
|
||||
private isInitialized = false;
|
||||
private connectionCount = 0;
|
||||
|
||||
private constructor(config: ConnectionManagerConfig) {
|
||||
this.config = config;
|
||||
this.logger = config.logger || defaultLogger.child('connection-manager');
|
||||
this.metrics = config.metricsCollector || defaultMetricsCollector;
|
||||
|
||||
// Initialize Elasticsearch client
|
||||
this.client = this.createClient(config);
|
||||
|
||||
// Initialize health checker
|
||||
this.healthChecker = new HealthChecker(this.client, {
|
||||
interval: config.pool?.maxIdleTime || 30000,
|
||||
timeout: config.request?.timeout || 5000,
|
||||
checkClusterHealth: true,
|
||||
});
|
||||
|
||||
// Initialize circuit breaker
|
||||
this.circuitBreaker = new CircuitBreaker('elasticsearch', {
|
||||
enabled: config.enableCircuitBreaker !== false,
|
||||
failureThreshold: 5,
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
this.logger.info('Elasticsearch connection manager created', {
|
||||
nodes: config.nodes,
|
||||
poolEnabled: !!config.pool,
|
||||
healthCheckEnabled: config.enableHealthCheck !== false,
|
||||
circuitBreakerEnabled: config.enableCircuitBreaker !== false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(config?: ConnectionManagerConfig): ElasticsearchConnectionManager {
|
||||
if (!ElasticsearchConnectionManager.instance) {
|
||||
if (!config) {
|
||||
throw new Error('Configuration required for first initialization');
|
||||
}
|
||||
ElasticsearchConnectionManager.instance = new ElasticsearchConnectionManager(config);
|
||||
}
|
||||
return ElasticsearchConnectionManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset singleton instance (useful for testing)
|
||||
*/
|
||||
static resetInstance(): void {
|
||||
if (ElasticsearchConnectionManager.instance) {
|
||||
ElasticsearchConnectionManager.instance.destroy();
|
||||
ElasticsearchConnectionManager.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Elasticsearch client with configuration
|
||||
*/
|
||||
private createClient(config: ConnectionManagerConfig): ElasticClient {
|
||||
const nodes = Array.isArray(config.nodes) ? config.nodes : [config.nodes];
|
||||
|
||||
const clientConfig: any = {
|
||||
nodes,
|
||||
};
|
||||
|
||||
// Authentication
|
||||
if (config.auth) {
|
||||
switch (config.auth.type) {
|
||||
case 'basic':
|
||||
clientConfig.auth = {
|
||||
username: config.auth.username,
|
||||
password: config.auth.password,
|
||||
};
|
||||
break;
|
||||
case 'apiKey':
|
||||
clientConfig.auth = {
|
||||
apiKey: config.auth.apiKey,
|
||||
};
|
||||
break;
|
||||
case 'bearer':
|
||||
clientConfig.auth = {
|
||||
bearer: config.auth.token,
|
||||
};
|
||||
break;
|
||||
case 'cloud':
|
||||
clientConfig.cloud = {
|
||||
id: config.auth.id,
|
||||
};
|
||||
if (config.auth.apiKey) {
|
||||
clientConfig.auth = { apiKey: config.auth.apiKey };
|
||||
} else if (config.auth.username && config.auth.password) {
|
||||
clientConfig.auth = {
|
||||
username: config.auth.username,
|
||||
password: config.auth.password,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// TLS configuration
|
||||
if (config.tls) {
|
||||
clientConfig.tls = config.tls;
|
||||
}
|
||||
|
||||
// Request configuration
|
||||
if (config.request) {
|
||||
clientConfig.requestTimeout = config.request.timeout;
|
||||
clientConfig.maxRetries = config.request.maxRetries;
|
||||
clientConfig.compression = config.request.compression;
|
||||
}
|
||||
|
||||
// Discovery/sniffing configuration
|
||||
if (config.discovery) {
|
||||
clientConfig.sniffOnStart = config.discovery.sniffOnStart;
|
||||
clientConfig.sniffInterval = config.discovery.interval;
|
||||
clientConfig.sniffOnConnectionFault = config.discovery.sniffOnConnectionFault;
|
||||
}
|
||||
|
||||
// Proxy
|
||||
if (config.proxy) {
|
||||
clientConfig.proxy = config.proxy;
|
||||
}
|
||||
|
||||
// Custom agent
|
||||
if (config.agent) {
|
||||
clientConfig.agent = config.agent;
|
||||
}
|
||||
|
||||
return new ElasticClient(clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize connection manager
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.info('Initializing connection manager...');
|
||||
|
||||
// Test connection
|
||||
await this.client.ping();
|
||||
this.logger.info('Successfully connected to Elasticsearch');
|
||||
|
||||
// Start health checks if enabled
|
||||
if (this.config.enableHealthCheck !== false) {
|
||||
this.healthChecker.startPeriodicChecks((result) => {
|
||||
this.onHealthChange(result);
|
||||
});
|
||||
this.logger.info('Health checks started');
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
this.metrics.activeConnections.set(1);
|
||||
|
||||
this.logger.info('Connection manager initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize connection manager', error as Error);
|
||||
throw new ConnectionError(
|
||||
'Failed to connect to Elasticsearch cluster',
|
||||
{
|
||||
operation: 'initialize',
|
||||
},
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Elasticsearch client
|
||||
*/
|
||||
getClient(): ElasticClient {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Connection manager not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
// Check circuit breaker
|
||||
if (this.circuitBreaker.isOpen()) {
|
||||
const stats = this.circuitBreaker.getStats();
|
||||
throw new ClusterUnavailableError(
|
||||
`Elasticsearch cluster unavailable. Circuit breaker open until ${stats.nextAttemptTime?.toISOString()}`,
|
||||
{
|
||||
circuitState: stats.state,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.connectionCount++;
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation through circuit breaker
|
||||
*/
|
||||
async execute<T>(operation: () => Promise<T>): Promise<T> {
|
||||
return this.circuitBreaker.execute(operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check callback
|
||||
*/
|
||||
private onHealthChange(result: HealthCheckResult): void {
|
||||
this.logger.info('Cluster health changed', {
|
||||
status: result.status,
|
||||
clusterHealth: result.clusterHealth,
|
||||
activeNodes: result.activeNodes,
|
||||
responseTimeMs: result.responseTimeMs,
|
||||
});
|
||||
|
||||
// Open circuit breaker if unhealthy
|
||||
if (result.status === HealthStatus.UNHEALTHY) {
|
||||
this.logger.warn('Cluster unhealthy, opening circuit breaker');
|
||||
this.circuitBreaker.open();
|
||||
} else if (result.status === HealthStatus.HEALTHY && this.circuitBreaker.isOpen()) {
|
||||
this.logger.info('Cluster recovered, closing circuit breaker');
|
||||
this.circuitBreaker.close();
|
||||
}
|
||||
|
||||
// Update metrics
|
||||
this.metrics.activeConnections.set(result.available ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform health check
|
||||
*/
|
||||
async healthCheck(): Promise<HealthCheckResult> {
|
||||
return this.healthChecker.check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current health status
|
||||
*/
|
||||
getHealthStatus(): HealthStatus {
|
||||
return this.healthChecker.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cluster is healthy
|
||||
*/
|
||||
isHealthy(): boolean {
|
||||
return this.healthChecker.isHealthy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cluster is available
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.healthChecker.isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker state
|
||||
*/
|
||||
getCircuitState(): string {
|
||||
return this.circuitBreaker.getState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
getStats(): {
|
||||
initialized: boolean;
|
||||
connectionCount: number;
|
||||
healthStatus: HealthStatus;
|
||||
circuitState: string;
|
||||
lastHealthCheck?: HealthCheckResult;
|
||||
} {
|
||||
return {
|
||||
initialized: this.isInitialized,
|
||||
connectionCount: this.connectionCount,
|
||||
healthStatus: this.healthChecker.getStatus(),
|
||||
circuitState: this.circuitBreaker.getState(),
|
||||
lastHealthCheck: this.healthChecker.getLastCheckResult(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup and close connections
|
||||
*/
|
||||
async destroy(): Promise<void> {
|
||||
this.logger.info('Destroying connection manager...');
|
||||
|
||||
// Stop health checks
|
||||
this.healthChecker.destroy();
|
||||
|
||||
// Close Elasticsearch client
|
||||
try {
|
||||
await this.client.close();
|
||||
this.logger.info('Elasticsearch client closed');
|
||||
} catch (error) {
|
||||
this.logger.error('Error closing Elasticsearch client', error as Error);
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
this.metrics.activeConnections.set(0);
|
||||
|
||||
this.logger.info('Connection manager destroyed');
|
||||
}
|
||||
}
|
||||
304
ts/core/connection/health-check.ts
Normal file
304
ts/core/connection/health-check.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { Client as ElasticClient } from '@elastic/elasticsearch';
|
||||
|
||||
/**
|
||||
* Health status
|
||||
*/
|
||||
export enum HealthStatus {
|
||||
HEALTHY = 'healthy',
|
||||
DEGRADED = 'degraded',
|
||||
UNHEALTHY = 'unhealthy',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster health status from Elasticsearch
|
||||
*/
|
||||
export enum ClusterHealth {
|
||||
GREEN = 'green',
|
||||
YELLOW = 'yellow',
|
||||
RED = 'red',
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check result
|
||||
*/
|
||||
export interface HealthCheckResult {
|
||||
/** Overall health status */
|
||||
status: HealthStatus;
|
||||
|
||||
/** Cluster health from Elasticsearch */
|
||||
clusterHealth?: ClusterHealth;
|
||||
|
||||
/** Whether the cluster is available */
|
||||
available: boolean;
|
||||
|
||||
/** Response time in milliseconds */
|
||||
responseTimeMs?: number;
|
||||
|
||||
/** Number of active nodes */
|
||||
activeNodes?: number;
|
||||
|
||||
/** Error if health check failed */
|
||||
error?: Error;
|
||||
|
||||
/** Timestamp of health check */
|
||||
timestamp: Date;
|
||||
|
||||
/** Additional details */
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check configuration
|
||||
*/
|
||||
export interface HealthCheckConfig {
|
||||
/** Interval between health checks in milliseconds */
|
||||
interval: number;
|
||||
|
||||
/** Timeout for health check requests */
|
||||
timeout: number;
|
||||
|
||||
/** Number of consecutive failures before marking unhealthy */
|
||||
unhealthyThreshold: number;
|
||||
|
||||
/** Number of consecutive successes before marking healthy */
|
||||
healthyThreshold: number;
|
||||
|
||||
/** Whether to check cluster health */
|
||||
checkClusterHealth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default health check configuration
|
||||
*/
|
||||
export const DEFAULT_HEALTH_CHECK_CONFIG: HealthCheckConfig = {
|
||||
interval: 30000, // 30 seconds
|
||||
timeout: 5000, // 5 seconds
|
||||
unhealthyThreshold: 3,
|
||||
healthyThreshold: 2,
|
||||
checkClusterHealth: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Health checker for Elasticsearch cluster
|
||||
*/
|
||||
export class HealthChecker {
|
||||
private config: HealthCheckConfig;
|
||||
private consecutiveFailures = 0;
|
||||
private consecutiveSuccesses = 0;
|
||||
private currentStatus: HealthStatus = HealthStatus.UNKNOWN;
|
||||
private lastCheckResult?: HealthCheckResult;
|
||||
private checkInterval?: NodeJS.Timeout;
|
||||
private isChecking = false;
|
||||
|
||||
constructor(
|
||||
private client: ElasticClient,
|
||||
config: Partial<HealthCheckConfig> = {}
|
||||
) {
|
||||
this.config = { ...DEFAULT_HEALTH_CHECK_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single health check
|
||||
*/
|
||||
async check(): Promise<HealthCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Ping the cluster
|
||||
const pingResponse = await Promise.race([
|
||||
this.client.ping(),
|
||||
this.timeout(this.config.timeout),
|
||||
]);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
const available = pingResponse === true || (pingResponse as any).statusCode === 200;
|
||||
|
||||
if (!available) {
|
||||
throw new Error('Cluster ping failed');
|
||||
}
|
||||
|
||||
// Check cluster health if enabled
|
||||
let clusterHealth: ClusterHealth | undefined;
|
||||
let activeNodes: number | undefined;
|
||||
|
||||
if (this.config.checkClusterHealth) {
|
||||
try {
|
||||
const healthResponse = await this.client.cluster.health({
|
||||
timeout: `${this.config.timeout}ms`,
|
||||
});
|
||||
|
||||
clusterHealth = healthResponse.status as ClusterHealth;
|
||||
activeNodes = healthResponse.number_of_nodes;
|
||||
} catch (error) {
|
||||
// Cluster health check failed, but ping succeeded
|
||||
// Mark as degraded
|
||||
this.consecutiveSuccesses = 0;
|
||||
this.consecutiveFailures++;
|
||||
|
||||
const result: HealthCheckResult = {
|
||||
status: HealthStatus.DEGRADED,
|
||||
available: true,
|
||||
responseTimeMs: responseTime,
|
||||
error: error as Error,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
this.lastCheckResult = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Success!
|
||||
this.consecutiveFailures = 0;
|
||||
this.consecutiveSuccesses++;
|
||||
|
||||
// Determine status based on cluster health
|
||||
let status: HealthStatus;
|
||||
if (clusterHealth === ClusterHealth.GREEN) {
|
||||
status = HealthStatus.HEALTHY;
|
||||
} else if (clusterHealth === ClusterHealth.YELLOW) {
|
||||
status = HealthStatus.DEGRADED;
|
||||
} else if (clusterHealth === ClusterHealth.RED) {
|
||||
status = HealthStatus.UNHEALTHY;
|
||||
} else {
|
||||
// No cluster health, but ping succeeded
|
||||
status =
|
||||
this.consecutiveSuccesses >= this.config.healthyThreshold
|
||||
? HealthStatus.HEALTHY
|
||||
: HealthStatus.DEGRADED;
|
||||
}
|
||||
|
||||
this.currentStatus = status;
|
||||
|
||||
const result: HealthCheckResult = {
|
||||
status,
|
||||
clusterHealth,
|
||||
available: true,
|
||||
responseTimeMs: responseTime,
|
||||
activeNodes,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
this.lastCheckResult = result;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.consecutiveSuccesses = 0;
|
||||
this.consecutiveFailures++;
|
||||
|
||||
const status =
|
||||
this.consecutiveFailures >= this.config.unhealthyThreshold
|
||||
? HealthStatus.UNHEALTHY
|
||||
: HealthStatus.DEGRADED;
|
||||
|
||||
this.currentStatus = status;
|
||||
|
||||
const result: HealthCheckResult = {
|
||||
status,
|
||||
available: false,
|
||||
error: error as Error,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
this.lastCheckResult = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic health checks
|
||||
*/
|
||||
startPeriodicChecks(onHealthChange?: (result: HealthCheckResult) => void): void {
|
||||
if (this.checkInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
const performCheck = async () => {
|
||||
if (this.isChecking) return;
|
||||
|
||||
this.isChecking = true;
|
||||
try {
|
||||
const previousStatus = this.currentStatus;
|
||||
const result = await this.check();
|
||||
|
||||
if (onHealthChange && result.status !== previousStatus) {
|
||||
onHealthChange(result);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error already handled in check()
|
||||
} finally {
|
||||
this.isChecking = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Perform initial check
|
||||
performCheck();
|
||||
|
||||
// Schedule periodic checks
|
||||
this.checkInterval = setInterval(performCheck, this.config.interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic health checks
|
||||
*/
|
||||
stopPeriodicChecks(): void {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
this.checkInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current health status
|
||||
*/
|
||||
getStatus(): HealthStatus {
|
||||
return this.currentStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last health check result
|
||||
*/
|
||||
getLastCheckResult(): HealthCheckResult | undefined {
|
||||
return this.lastCheckResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cluster is healthy
|
||||
*/
|
||||
isHealthy(): boolean {
|
||||
return this.currentStatus === HealthStatus.HEALTHY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cluster is available
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.lastCheckResult?.available ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset health check state
|
||||
*/
|
||||
reset(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
this.consecutiveSuccesses = 0;
|
||||
this.currentStatus = HealthStatus.UNKNOWN;
|
||||
this.lastCheckResult = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a timeout promise
|
||||
*/
|
||||
private timeout(ms: number): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Health check timeout after ${ms}ms`)), ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stopPeriodicChecks();
|
||||
}
|
||||
}
|
||||
15
ts/core/connection/index.ts
Normal file
15
ts/core/connection/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Connection management for Elasticsearch client
|
||||
*
|
||||
* This module provides:
|
||||
* - Connection pooling and lifecycle management
|
||||
* - Health monitoring with periodic checks
|
||||
* - Circuit breaker pattern for fault tolerance
|
||||
* - Automatic reconnection
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './health-check.js';
|
||||
export * from './circuit-breaker.js';
|
||||
export * from './connection-manager.js';
|
||||
327
ts/core/errors/elasticsearch-error.ts
Normal file
327
ts/core/errors/elasticsearch-error.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { ErrorCode, ErrorContext } from './types.js';
|
||||
|
||||
/**
|
||||
* Base error class for all Elasticsearch client errors
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* throw new ElasticsearchError('Connection failed', {
|
||||
* code: ErrorCode.CONNECTION_FAILED,
|
||||
* retryable: true,
|
||||
* context: {
|
||||
* timestamp: new Date(),
|
||||
* operation: 'connect',
|
||||
* statusCode: 503
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class ElasticsearchError extends Error {
|
||||
/** Error code for categorization */
|
||||
public readonly code: ErrorCode;
|
||||
|
||||
/** Whether this error is retryable */
|
||||
public readonly retryable: boolean;
|
||||
|
||||
/** Additional context about the error */
|
||||
public readonly context: ErrorContext;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
code: ErrorCode;
|
||||
retryable: boolean;
|
||||
context: ErrorContext;
|
||||
cause?: Error;
|
||||
}
|
||||
) {
|
||||
super(message, { cause: options.cause });
|
||||
this.name = this.constructor.name;
|
||||
this.code = options.code;
|
||||
this.retryable = options.retryable;
|
||||
this.context = {
|
||||
...options.context,
|
||||
timestamp: options.context.timestamp || new Date(),
|
||||
};
|
||||
|
||||
// Maintains proper stack trace for where error was thrown (V8 only)
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert error to JSON for logging/serialization
|
||||
*/
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
retryable: this.retryable,
|
||||
context: this.context,
|
||||
stack: this.stack,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is of a specific code
|
||||
*/
|
||||
is(code: ErrorCode): boolean {
|
||||
return this.code === code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is retryable
|
||||
*/
|
||||
canRetry(): boolean {
|
||||
return this.retryable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection-related errors
|
||||
*/
|
||||
export class ConnectionError extends ElasticsearchError {
|
||||
constructor(message: string, context: Partial<ErrorContext> = {}, cause?: Error) {
|
||||
super(message, {
|
||||
code: ErrorCode.CONNECTION_FAILED,
|
||||
retryable: true,
|
||||
context: {
|
||||
...context,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout errors
|
||||
*/
|
||||
export class TimeoutError extends ElasticsearchError {
|
||||
constructor(
|
||||
message: string,
|
||||
operation: string,
|
||||
timeoutMs: number,
|
||||
context: Partial<ErrorContext> = {},
|
||||
cause?: Error
|
||||
) {
|
||||
super(message, {
|
||||
code: ErrorCode.REQUEST_TIMEOUT,
|
||||
retryable: true,
|
||||
context: {
|
||||
...context,
|
||||
operation,
|
||||
timeout: timeoutMs,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index not found error
|
||||
*/
|
||||
export class IndexNotFoundError extends ElasticsearchError {
|
||||
constructor(indexName: string, context: Partial<ErrorContext> = {}, cause?: Error) {
|
||||
super(`Index not found: ${indexName}`, {
|
||||
code: ErrorCode.INDEX_NOT_FOUND,
|
||||
retryable: false,
|
||||
context: {
|
||||
...context,
|
||||
index: indexName,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Document not found error
|
||||
*/
|
||||
export class DocumentNotFoundError extends ElasticsearchError {
|
||||
constructor(
|
||||
documentId: string,
|
||||
indexName?: string,
|
||||
context: Partial<ErrorContext> = {},
|
||||
cause?: Error
|
||||
) {
|
||||
super(`Document not found: ${documentId}${indexName ? ` in index ${indexName}` : ''}`, {
|
||||
code: ErrorCode.DOCUMENT_NOT_FOUND,
|
||||
retryable: false,
|
||||
context: {
|
||||
...context,
|
||||
documentId,
|
||||
index: indexName,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Document conflict error (version mismatch, optimistic locking)
|
||||
*/
|
||||
export class DocumentConflictError extends ElasticsearchError {
|
||||
constructor(
|
||||
documentId: string,
|
||||
message: string,
|
||||
context: Partial<ErrorContext> = {},
|
||||
cause?: Error
|
||||
) {
|
||||
super(message, {
|
||||
code: ErrorCode.DOCUMENT_CONFLICT,
|
||||
retryable: true, // Can retry with updated version
|
||||
context: {
|
||||
...context,
|
||||
documentId,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication error
|
||||
*/
|
||||
export class AuthenticationError extends ElasticsearchError {
|
||||
constructor(message: string, context: Partial<ErrorContext> = {}, cause?: Error) {
|
||||
super(message, {
|
||||
code: ErrorCode.AUTHENTICATION_FAILED,
|
||||
retryable: false,
|
||||
context: {
|
||||
...context,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization error (insufficient permissions)
|
||||
*/
|
||||
export class AuthorizationError extends ElasticsearchError {
|
||||
constructor(
|
||||
operation: string,
|
||||
resource: string,
|
||||
context: Partial<ErrorContext> = {},
|
||||
cause?: Error
|
||||
) {
|
||||
super(`Not authorized to perform ${operation} on ${resource}`, {
|
||||
code: ErrorCode.AUTHORIZATION_FAILED,
|
||||
retryable: false,
|
||||
context: {
|
||||
...context,
|
||||
operation,
|
||||
resource,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration error
|
||||
*/
|
||||
export class ConfigurationError extends ElasticsearchError {
|
||||
constructor(message: string, context: Partial<ErrorContext> = {}, cause?: Error) {
|
||||
super(message, {
|
||||
code: ErrorCode.INVALID_CONFIGURATION,
|
||||
retryable: false,
|
||||
context: {
|
||||
...context,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parsing error
|
||||
*/
|
||||
export class QueryParseError extends ElasticsearchError {
|
||||
constructor(query: unknown, reason: string, context: Partial<ErrorContext> = {}, cause?: Error) {
|
||||
super(`Failed to parse query: ${reason}`, {
|
||||
code: ErrorCode.QUERY_PARSE_ERROR,
|
||||
retryable: false,
|
||||
context: {
|
||||
...context,
|
||||
query,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk operation error with partial failures
|
||||
*/
|
||||
export class BulkOperationError extends ElasticsearchError {
|
||||
public readonly successfulCount: number;
|
||||
public readonly failedCount: number;
|
||||
public readonly failures: Array<{
|
||||
documentId?: string;
|
||||
error: string;
|
||||
status: number;
|
||||
}>;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
successful: number,
|
||||
failed: number,
|
||||
failures: Array<{ documentId?: string; error: string; status: number }>,
|
||||
context: Partial<ErrorContext> = {},
|
||||
cause?: Error
|
||||
) {
|
||||
super(message, {
|
||||
code: failed === 0 ? ErrorCode.BULK_REQUEST_FAILED : ErrorCode.PARTIAL_BULK_FAILURE,
|
||||
retryable: true, // Failed items can be retried
|
||||
context: {
|
||||
...context,
|
||||
successfulCount: successful,
|
||||
failedCount: failed,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
|
||||
this.successfulCount = successful;
|
||||
this.failedCount = failed;
|
||||
this.failures = failures;
|
||||
}
|
||||
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
successfulCount: this.successfulCount,
|
||||
failedCount: this.failedCount,
|
||||
failures: this.failures,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster unavailable error
|
||||
*/
|
||||
export class ClusterUnavailableError extends ElasticsearchError {
|
||||
constructor(message: string, context: Partial<ErrorContext> = {}, cause?: Error) {
|
||||
super(message, {
|
||||
code: ErrorCode.CLUSTER_UNAVAILABLE,
|
||||
retryable: true,
|
||||
context: {
|
||||
...context,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
14
ts/core/errors/index.ts
Normal file
14
ts/core/errors/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Core error handling for Elasticsearch client
|
||||
*
|
||||
* This module provides:
|
||||
* - Typed error hierarchy with specific error classes
|
||||
* - Retry policies with configurable strategies
|
||||
* - Error context and metadata
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './types.js';
|
||||
export * from './elasticsearch-error.js';
|
||||
export * from './retry-policy.js';
|
||||
196
ts/core/errors/retry-policy.ts
Normal file
196
ts/core/errors/retry-policy.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { RetryConfig, RetryStrategy } from './types.js';
|
||||
import { ElasticsearchError } from './elasticsearch-error.js';
|
||||
|
||||
/**
|
||||
* Calculates delay based on retry strategy
|
||||
*/
|
||||
export class RetryDelayCalculator {
|
||||
constructor(private config: RetryConfig) {}
|
||||
|
||||
/**
|
||||
* Calculate delay for the given attempt number
|
||||
*/
|
||||
calculateDelay(attempt: number): number {
|
||||
let delay: number;
|
||||
|
||||
switch (this.config.strategy) {
|
||||
case 'none':
|
||||
return 0;
|
||||
|
||||
case 'fixed':
|
||||
delay = this.config.initialDelay;
|
||||
break;
|
||||
|
||||
case 'linear':
|
||||
delay = this.config.initialDelay * attempt;
|
||||
break;
|
||||
|
||||
case 'exponential':
|
||||
const multiplier = this.config.backoffMultiplier || 2;
|
||||
delay = this.config.initialDelay * Math.pow(multiplier, attempt - 1);
|
||||
break;
|
||||
|
||||
default:
|
||||
delay = this.config.initialDelay;
|
||||
}
|
||||
|
||||
// Cap at max delay
|
||||
delay = Math.min(delay, this.config.maxDelay);
|
||||
|
||||
// Add jitter if configured
|
||||
if (this.config.jitterFactor && this.config.jitterFactor > 0) {
|
||||
const jitter = delay * this.config.jitterFactor * Math.random();
|
||||
delay = delay + jitter;
|
||||
}
|
||||
|
||||
return Math.floor(delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default retry configuration
|
||||
*/
|
||||
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
||||
maxAttempts: 3,
|
||||
strategy: 'exponential',
|
||||
initialDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
backoffMultiplier: 2,
|
||||
jitterFactor: 0.1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if an error should be retried based on its characteristics
|
||||
*/
|
||||
export function shouldRetryError(error: Error): boolean {
|
||||
// If it's our custom error, check the retryable flag
|
||||
if (error instanceof ElasticsearchError) {
|
||||
return error.retryable;
|
||||
}
|
||||
|
||||
// For native errors, check specific types
|
||||
if (error.name === 'TimeoutError') return true;
|
||||
if (error.message.includes('ECONNREFUSED')) return true;
|
||||
if (error.message.includes('ECONNRESET')) return true;
|
||||
if (error.message.includes('ETIMEDOUT')) return true;
|
||||
if (error.message.includes('ENETUNREACH')) return true;
|
||||
if (error.message.includes('EHOSTUNREACH')) return true;
|
||||
|
||||
// HTTP status codes that are retryable
|
||||
if ('statusCode' in error) {
|
||||
const statusCode = (error as any).statusCode;
|
||||
if (statusCode === 429) return true; // Too Many Requests
|
||||
if (statusCode === 503) return true; // Service Unavailable
|
||||
if (statusCode === 504) return true; // Gateway Timeout
|
||||
if (statusCode >= 500 && statusCode < 600) return true; // Server errors
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry policy for executing operations with automatic retry
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const policy = new RetryPolicy({
|
||||
* maxAttempts: 5,
|
||||
* strategy: 'exponential',
|
||||
* initialDelay: 1000,
|
||||
* maxDelay: 30000,
|
||||
* });
|
||||
*
|
||||
* const result = await policy.execute(async () => {
|
||||
* return await someElasticsearchOperation();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class RetryPolicy {
|
||||
private config: RetryConfig;
|
||||
private delayCalculator: RetryDelayCalculator;
|
||||
|
||||
constructor(config: Partial<RetryConfig> = {}) {
|
||||
this.config = { ...DEFAULT_RETRY_CONFIG, ...config };
|
||||
this.delayCalculator = new RetryDelayCalculator(this.config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation with retry logic
|
||||
*/
|
||||
async execute<T>(
|
||||
operation: () => Promise<T>,
|
||||
context?: {
|
||||
operationName?: string;
|
||||
onRetry?: (attempt: number, error: Error, delayMs: number) => void;
|
||||
}
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt < this.config.maxAttempts) {
|
||||
attempt++;
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
// Check if we should retry
|
||||
const shouldRetry = this.shouldRetry(lastError, attempt);
|
||||
|
||||
if (!shouldRetry || attempt >= this.config.maxAttempts) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Calculate delay
|
||||
const delay = this.delayCalculator.calculateDelay(attempt);
|
||||
|
||||
// Call retry callback if provided
|
||||
if (context?.onRetry) {
|
||||
context.onRetry(attempt, lastError, delay);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here, but TypeScript doesn't know that
|
||||
throw lastError || new Error('Retry policy exhausted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error should be retried
|
||||
*/
|
||||
private shouldRetry(error: Error, attempt: number): boolean {
|
||||
// Check custom shouldRetry function first
|
||||
if (this.config.shouldRetry) {
|
||||
return this.config.shouldRetry(error, attempt);
|
||||
}
|
||||
|
||||
// Use default retry logic
|
||||
return shouldRetryError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for the specified number of milliseconds
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): RetryConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<RetryConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
this.delayCalculator = new RetryDelayCalculator(this.config);
|
||||
}
|
||||
}
|
||||
119
ts/core/errors/types.ts
Normal file
119
ts/core/errors/types.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Error codes for categorizing Elasticsearch client errors
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
// Connection errors
|
||||
CONNECTION_FAILED = 'CONNECTION_FAILED',
|
||||
CONNECTION_TIMEOUT = 'CONNECTION_TIMEOUT',
|
||||
CONNECTION_REFUSED = 'CONNECTION_REFUSED',
|
||||
|
||||
// Request errors
|
||||
REQUEST_TIMEOUT = 'REQUEST_TIMEOUT',
|
||||
REQUEST_ABORTED = 'REQUEST_ABORTED',
|
||||
INVALID_REQUEST = 'INVALID_REQUEST',
|
||||
|
||||
// Response errors
|
||||
RESPONSE_ERROR = 'RESPONSE_ERROR',
|
||||
PARSE_ERROR = 'PARSE_ERROR',
|
||||
|
||||
// Index errors
|
||||
INDEX_NOT_FOUND = 'INDEX_NOT_FOUND',
|
||||
INDEX_ALREADY_EXISTS = 'INDEX_ALREADY_EXISTS',
|
||||
INVALID_INDEX_NAME = 'INVALID_INDEX_NAME',
|
||||
|
||||
// Document errors
|
||||
DOCUMENT_NOT_FOUND = 'DOCUMENT_NOT_FOUND',
|
||||
DOCUMENT_ALREADY_EXISTS = 'DOCUMENT_ALREADY_EXISTS',
|
||||
DOCUMENT_CONFLICT = 'DOCUMENT_CONFLICT',
|
||||
VERSION_CONFLICT = 'VERSION_CONFLICT',
|
||||
|
||||
// Authentication & Authorization
|
||||
AUTHENTICATION_FAILED = 'AUTHENTICATION_FAILED',
|
||||
AUTHORIZATION_FAILED = 'AUTHORIZATION_FAILED',
|
||||
INVALID_API_KEY = 'INVALID_API_KEY',
|
||||
|
||||
// Cluster errors
|
||||
CLUSTER_UNAVAILABLE = 'CLUSTER_UNAVAILABLE',
|
||||
NODE_UNAVAILABLE = 'NODE_UNAVAILABLE',
|
||||
SHARD_FAILURE = 'SHARD_FAILURE',
|
||||
|
||||
// Query errors
|
||||
QUERY_PARSE_ERROR = 'QUERY_PARSE_ERROR',
|
||||
INVALID_QUERY = 'INVALID_QUERY',
|
||||
SEARCH_PHASE_EXECUTION_ERROR = 'SEARCH_PHASE_EXECUTION_ERROR',
|
||||
|
||||
// Bulk errors
|
||||
BULK_REQUEST_FAILED = 'BULK_REQUEST_FAILED',
|
||||
PARTIAL_BULK_FAILURE = 'PARTIAL_BULK_FAILURE',
|
||||
|
||||
// Configuration errors
|
||||
INVALID_CONFIGURATION = 'INVALID_CONFIGURATION',
|
||||
MISSING_REQUIRED_CONFIG = 'MISSING_REQUIRED_CONFIG',
|
||||
|
||||
// Generic errors
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
||||
INTERNAL_ERROR = 'INTERNAL_ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional context for errors
|
||||
*/
|
||||
export interface ErrorContext {
|
||||
/** Timestamp when error occurred */
|
||||
timestamp: Date;
|
||||
|
||||
/** Operation that failed */
|
||||
operation?: string;
|
||||
|
||||
/** Index name if applicable */
|
||||
index?: string;
|
||||
|
||||
/** Document ID if applicable */
|
||||
documentId?: string;
|
||||
|
||||
/** HTTP status code if applicable */
|
||||
statusCode?: number;
|
||||
|
||||
/** Elasticsearch error type */
|
||||
elasticsearchType?: string;
|
||||
|
||||
/** Elasticsearch error reason */
|
||||
elasticsearchReason?: string;
|
||||
|
||||
/** Original error */
|
||||
originalError?: Error;
|
||||
|
||||
/** Additional metadata */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry strategy types
|
||||
*/
|
||||
export type RetryStrategy = 'none' | 'fixed' | 'exponential' | 'linear';
|
||||
|
||||
/**
|
||||
* Configuration for retry behavior
|
||||
*/
|
||||
export interface RetryConfig {
|
||||
/** Maximum number of retry attempts */
|
||||
maxAttempts: number;
|
||||
|
||||
/** Delay strategy */
|
||||
strategy: RetryStrategy;
|
||||
|
||||
/** Initial delay in milliseconds */
|
||||
initialDelay: number;
|
||||
|
||||
/** Maximum delay in milliseconds */
|
||||
maxDelay: number;
|
||||
|
||||
/** Multiplier for exponential backoff */
|
||||
backoffMultiplier?: number;
|
||||
|
||||
/** Jitter factor (0-1) to add randomness */
|
||||
jitterFactor?: number;
|
||||
|
||||
/** Custom function to determine if error should be retried */
|
||||
shouldRetry?: (error: Error, attempt: number) => boolean;
|
||||
}
|
||||
17
ts/core/index.ts
Normal file
17
ts/core/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Core infrastructure for Elasticsearch client
|
||||
*
|
||||
* This module provides the foundation layers:
|
||||
* - Configuration management
|
||||
* - Connection pooling and lifecycle
|
||||
* - Error handling and retry logic
|
||||
* - Observability (logging, metrics, tracing)
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './config/index.js';
|
||||
export * from './connection/index.js';
|
||||
export * from './errors/index.js';
|
||||
export * from './observability/index.js';
|
||||
export * from './plugins/index.js';
|
||||
14
ts/core/observability/index.ts
Normal file
14
ts/core/observability/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Observability layer for Elasticsearch client
|
||||
*
|
||||
* This module provides:
|
||||
* - Structured logging with context and correlation
|
||||
* - Prometheus-compatible metrics collection
|
||||
* - Distributed tracing with OpenTelemetry-compatible API
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './logger.js';
|
||||
export * from './metrics.js';
|
||||
export * from './tracing.js';
|
||||
281
ts/core/observability/logger.ts
Normal file
281
ts/core/observability/logger.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Log levels in order of severity
|
||||
*/
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
/**
|
||||
* Log level priorities for filtering
|
||||
*/
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
[LogLevel.DEBUG]: 0,
|
||||
[LogLevel.INFO]: 1,
|
||||
[LogLevel.WARN]: 2,
|
||||
[LogLevel.ERROR]: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Structured log entry
|
||||
*/
|
||||
export interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
context?: Record<string, unknown>;
|
||||
correlationId?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log transport interface for custom log handlers
|
||||
*/
|
||||
export interface LogTransport {
|
||||
log(entry: LogEntry): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Console transport with colored output
|
||||
*/
|
||||
export class ConsoleTransport implements LogTransport {
|
||||
private readonly colors = {
|
||||
debug: '\x1b[36m', // Cyan
|
||||
info: '\x1b[32m', // Green
|
||||
warn: '\x1b[33m', // Yellow
|
||||
error: '\x1b[31m', // Red
|
||||
reset: '\x1b[0m',
|
||||
};
|
||||
|
||||
log(entry: LogEntry): void {
|
||||
const color = this.colors[entry.level];
|
||||
const reset = this.colors.reset;
|
||||
const timestamp = entry.timestamp.toISOString();
|
||||
const level = entry.level.toUpperCase().padEnd(5);
|
||||
|
||||
let message = `${color}[${timestamp}] ${level}${reset} ${entry.message}`;
|
||||
|
||||
if (entry.correlationId) {
|
||||
message += ` ${color}[correlation: ${entry.correlationId}]${reset}`;
|
||||
}
|
||||
|
||||
if (entry.context && Object.keys(entry.context).length > 0) {
|
||||
message += `\n Context: ${JSON.stringify(entry.context, null, 2)}`;
|
||||
}
|
||||
|
||||
if (entry.error) {
|
||||
message += `\n Error: ${entry.error.message}`;
|
||||
if (entry.error.stack) {
|
||||
message += `\n${entry.error.stack}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON transport for structured logging
|
||||
*/
|
||||
export class JsonTransport implements LogTransport {
|
||||
log(entry: LogEntry): void {
|
||||
const jsonEntry = {
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
...(entry.correlationId && { correlationId: entry.correlationId }),
|
||||
...(entry.context && { context: entry.context }),
|
||||
...(entry.error && {
|
||||
error: {
|
||||
message: entry.error.message,
|
||||
name: entry.error.name,
|
||||
stack: entry.error.stack,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(jsonEntry));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger configuration
|
||||
*/
|
||||
export interface LoggerConfig {
|
||||
/** Minimum log level to output */
|
||||
level: LogLevel;
|
||||
|
||||
/** Log transports */
|
||||
transports: LogTransport[];
|
||||
|
||||
/** Default context to include in all logs */
|
||||
defaultContext?: Record<string, unknown>;
|
||||
|
||||
/** Whether to include timestamp */
|
||||
includeTimestamp?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured logger with context and correlation support
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const logger = new Logger({
|
||||
* level: LogLevel.INFO,
|
||||
* transports: [new ConsoleTransport()],
|
||||
* defaultContext: { service: 'elasticsearch-client' }
|
||||
* });
|
||||
*
|
||||
* logger.info('Connected to Elasticsearch', { node: 'localhost:9200' });
|
||||
*
|
||||
* const childLogger = logger.withContext({ operation: 'bulk-index' });
|
||||
* childLogger.debug('Processing batch', { size: 1000 });
|
||||
* ```
|
||||
*/
|
||||
export class Logger {
|
||||
private config: LoggerConfig;
|
||||
private context: Record<string, unknown>;
|
||||
private correlationId?: string;
|
||||
|
||||
constructor(config: Partial<LoggerConfig> = {}) {
|
||||
this.config = {
|
||||
level: config.level || LogLevel.INFO,
|
||||
transports: config.transports || [new ConsoleTransport()],
|
||||
defaultContext: config.defaultContext || {},
|
||||
includeTimestamp: config.includeTimestamp !== false,
|
||||
};
|
||||
this.context = { ...this.config.defaultContext };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with additional context
|
||||
*/
|
||||
withContext(context: Record<string, unknown>): Logger {
|
||||
const child = new Logger(this.config);
|
||||
child.context = { ...this.context, ...context };
|
||||
child.correlationId = this.correlationId;
|
||||
return child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with correlation ID
|
||||
*/
|
||||
withCorrelation(correlationId: string): Logger {
|
||||
const child = new Logger(this.config);
|
||||
child.context = { ...this.context };
|
||||
child.correlationId = correlationId;
|
||||
return child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger for a specific namespace
|
||||
*/
|
||||
child(namespace: string): Logger {
|
||||
return this.withContext({ namespace });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at DEBUG level
|
||||
*/
|
||||
debug(message: string, meta?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.DEBUG, message, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at INFO level
|
||||
*/
|
||||
info(message: string, meta?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.INFO, message, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at WARN level
|
||||
*/
|
||||
warn(message: string, meta?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.WARN, message, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log at ERROR level
|
||||
*/
|
||||
error(message: string, error?: Error, meta?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.ERROR, message, meta, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal log method
|
||||
*/
|
||||
private log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
meta?: Record<string, unknown>,
|
||||
error?: Error
|
||||
): void {
|
||||
// Check if we should log this level
|
||||
if (!this.shouldLog(level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
context: { ...this.context, ...meta },
|
||||
...(this.correlationId && { correlationId: this.correlationId }),
|
||||
...(error && { error }),
|
||||
};
|
||||
|
||||
// Send to all transports
|
||||
for (const transport of this.config.transports) {
|
||||
try {
|
||||
const result = transport.log(entry);
|
||||
// Handle async transports
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch((err) => {
|
||||
console.error('Transport error:', err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transport error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log level should be output
|
||||
*/
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.config.level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update logger configuration
|
||||
*/
|
||||
setLevel(level: LogLevel): void {
|
||||
this.config.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a transport
|
||||
*/
|
||||
addTransport(transport: LogTransport): void {
|
||||
this.config.transports.push(transport);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current log level
|
||||
*/
|
||||
getLevel(): LogLevel {
|
||||
return this.config.level;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default logger instance
|
||||
*/
|
||||
export const defaultLogger = new Logger({
|
||||
level: LogLevel.INFO,
|
||||
transports: [new ConsoleTransport()],
|
||||
});
|
||||
543
ts/core/observability/metrics.ts
Normal file
543
ts/core/observability/metrics.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Label map for metrics
|
||||
*/
|
||||
export type Labels = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* Metric types
|
||||
*/
|
||||
export enum MetricType {
|
||||
COUNTER = 'counter',
|
||||
GAUGE = 'gauge',
|
||||
HISTOGRAM = 'histogram',
|
||||
}
|
||||
|
||||
/**
|
||||
* Histogram bucket configuration
|
||||
*/
|
||||
export interface HistogramBuckets {
|
||||
buckets: number[];
|
||||
counts: Map<number, number>;
|
||||
sum: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base metric class
|
||||
*/
|
||||
abstract class Metric {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly type: MetricType,
|
||||
public readonly help: string,
|
||||
public readonly labels: string[] = []
|
||||
) {}
|
||||
|
||||
abstract getValue(labels?: Labels): number | HistogramBuckets;
|
||||
abstract reset(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter metric - monotonically increasing value
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const requestCounter = new Counter('http_requests_total', 'Total HTTP requests', ['method', 'status']);
|
||||
* requestCounter.inc({ method: 'GET', status: '200' });
|
||||
* requestCounter.inc({ method: 'POST', status: '201' }, 5);
|
||||
* ```
|
||||
*/
|
||||
export class Counter extends Metric {
|
||||
private values: Map<string, number> = new Map();
|
||||
|
||||
constructor(name: string, help: string, labels: string[] = []) {
|
||||
super(name, MetricType.COUNTER, help, labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment counter
|
||||
*/
|
||||
inc(labels: Labels = {}, value: number = 1): void {
|
||||
if (value < 0) {
|
||||
throw new Error('Counter can only be incremented with positive values');
|
||||
}
|
||||
|
||||
const key = this.getKey(labels);
|
||||
const current = this.values.get(key) || 0;
|
||||
this.values.set(key, current + value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current value
|
||||
*/
|
||||
getValue(labels: Labels = {}): number {
|
||||
const key = this.getKey(labels);
|
||||
return this.values.get(key) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset counter
|
||||
*/
|
||||
reset(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values with labels
|
||||
*/
|
||||
getAll(): Array<{ labels: Labels; value: number }> {
|
||||
const results: Array<{ labels: Labels; value: number }> = [];
|
||||
|
||||
for (const [key, value] of this.values.entries()) {
|
||||
const labels = this.parseKey(key);
|
||||
results.push({ labels, value });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private getKey(labels: Labels): string {
|
||||
const labelStr = this.labels
|
||||
.map((label) => `${label}=${labels[label] || ''}`)
|
||||
.join(',');
|
||||
return labelStr || 'default';
|
||||
}
|
||||
|
||||
private parseKey(key: string): Labels {
|
||||
if (key === 'default') return {};
|
||||
|
||||
const labels: Labels = {};
|
||||
const pairs = key.split(',');
|
||||
|
||||
for (const pair of pairs) {
|
||||
const [name, value] = pair.split('=');
|
||||
if (name && value !== undefined) {
|
||||
labels[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gauge metric - value that can go up and down
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const activeConnections = new Gauge('active_connections', 'Number of active connections');
|
||||
* activeConnections.set(42);
|
||||
* activeConnections.inc();
|
||||
* activeConnections.dec(5);
|
||||
* ```
|
||||
*/
|
||||
export class Gauge extends Metric {
|
||||
private values: Map<string, number> = new Map();
|
||||
|
||||
constructor(name: string, help: string, labels: string[] = []) {
|
||||
super(name, MetricType.GAUGE, help, labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set gauge to a specific value
|
||||
*/
|
||||
set(value: number, labels: Labels = {}): void {
|
||||
const key = this.getKey(labels);
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment gauge
|
||||
*/
|
||||
inc(labels: Labels = {}, value: number = 1): void {
|
||||
const key = this.getKey(labels);
|
||||
const current = this.values.get(key) || 0;
|
||||
this.values.set(key, current + value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement gauge
|
||||
*/
|
||||
dec(labels: Labels = {}, value: number = 1): void {
|
||||
this.inc(labels, -value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current value
|
||||
*/
|
||||
getValue(labels: Labels = {}): number {
|
||||
const key = this.getKey(labels);
|
||||
return this.values.get(key) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset gauge
|
||||
*/
|
||||
reset(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values with labels
|
||||
*/
|
||||
getAll(): Array<{ labels: Labels; value: number }> {
|
||||
const results: Array<{ labels: Labels; value: number }> = [];
|
||||
|
||||
for (const [key, value] of this.values.entries()) {
|
||||
const labels = this.parseKey(key);
|
||||
results.push({ labels, value });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private getKey(labels: Labels): string {
|
||||
const labelStr = this.labels
|
||||
.map((label) => `${label}=${labels[label] || ''}`)
|
||||
.join(',');
|
||||
return labelStr || 'default';
|
||||
}
|
||||
|
||||
private parseKey(key: string): Labels {
|
||||
if (key === 'default') return {};
|
||||
|
||||
const labels: Labels = {};
|
||||
const pairs = key.split(',');
|
||||
|
||||
for (const pair of pairs) {
|
||||
const [name, value] = pair.split('=');
|
||||
if (name && value !== undefined) {
|
||||
labels[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Histogram metric - tracks distribution of values
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const latency = new Histogram('request_duration_seconds', 'Request latency', ['endpoint'], [0.1, 0.5, 1, 2, 5]);
|
||||
* latency.observe(0.234, { endpoint: '/api/users' });
|
||||
* latency.observe(1.567, { endpoint: '/api/users' });
|
||||
* ```
|
||||
*/
|
||||
export class Histogram extends Metric {
|
||||
private buckets: number[];
|
||||
private values: Map<string, HistogramBuckets> = new Map();
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
help: string,
|
||||
labels: string[] = [],
|
||||
buckets: number[] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
|
||||
) {
|
||||
super(name, MetricType.HISTOGRAM, help, labels);
|
||||
this.buckets = [...buckets].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe a value
|
||||
*/
|
||||
observe(value: number, labels: Labels = {}): void {
|
||||
const key = this.getKey(labels);
|
||||
let bucketData = this.values.get(key);
|
||||
|
||||
if (!bucketData) {
|
||||
bucketData = {
|
||||
buckets: this.buckets,
|
||||
counts: new Map(this.buckets.map((b) => [b, 0])),
|
||||
sum: 0,
|
||||
count: 0,
|
||||
};
|
||||
this.values.set(key, bucketData);
|
||||
}
|
||||
|
||||
// Update bucket counts
|
||||
for (const bucket of this.buckets) {
|
||||
if (value <= bucket) {
|
||||
const current = bucketData.counts.get(bucket) || 0;
|
||||
bucketData.counts.set(bucket, current + 1);
|
||||
}
|
||||
}
|
||||
|
||||
bucketData.sum += value;
|
||||
bucketData.count++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get histogram data
|
||||
*/
|
||||
getValue(labels: Labels = {}): HistogramBuckets {
|
||||
const key = this.getKey(labels);
|
||||
return (
|
||||
this.values.get(key) || {
|
||||
buckets: this.buckets,
|
||||
counts: new Map(this.buckets.map((b) => [b, 0])),
|
||||
sum: 0,
|
||||
count: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset histogram
|
||||
*/
|
||||
reset(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all histogram data with labels
|
||||
*/
|
||||
getAll(): Array<{ labels: Labels; value: HistogramBuckets }> {
|
||||
const results: Array<{ labels: Labels; value: HistogramBuckets }> = [];
|
||||
|
||||
for (const [key, value] of this.values.entries()) {
|
||||
const labels = this.parseKey(key);
|
||||
results.push({ labels, value });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private getKey(labels: Labels): string {
|
||||
const labelStr = this.labels
|
||||
.map((label) => `${label}=${labels[label] || ''}`)
|
||||
.join(',');
|
||||
return labelStr || 'default';
|
||||
}
|
||||
|
||||
private parseKey(key: string): Labels {
|
||||
if (key === 'default') return {};
|
||||
|
||||
const labels: Labels = {};
|
||||
const pairs = key.split(',');
|
||||
|
||||
for (const pair of pairs) {
|
||||
const [name, value] = pair.split('=');
|
||||
if (name && value !== undefined) {
|
||||
labels[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Metrics registry
|
||||
*/
|
||||
export class MetricsRegistry {
|
||||
private metrics: Map<string, Metric> = new Map();
|
||||
|
||||
/**
|
||||
* Register a metric
|
||||
*/
|
||||
register(metric: Metric): void {
|
||||
if (this.metrics.has(metric.name)) {
|
||||
throw new Error(`Metric ${metric.name} already registered`);
|
||||
}
|
||||
this.metrics.set(metric.name, metric);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a metric by name
|
||||
*/
|
||||
get(name: string): Metric | undefined {
|
||||
return this.metrics.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics
|
||||
*/
|
||||
getAll(): Metric[] {
|
||||
return Array.from(this.metrics.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all metrics
|
||||
*/
|
||||
clear(): void {
|
||||
this.metrics.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all metric values
|
||||
*/
|
||||
reset(): void {
|
||||
for (const metric of this.metrics.values()) {
|
||||
metric.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export metrics in Prometheus text format
|
||||
*/
|
||||
export(): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const metric of this.metrics.values()) {
|
||||
// Add help text
|
||||
lines.push(`# HELP ${metric.name} ${metric.help}`);
|
||||
lines.push(`# TYPE ${metric.name} ${metric.type}`);
|
||||
|
||||
if (metric instanceof Counter || metric instanceof Gauge) {
|
||||
const all = metric.getAll();
|
||||
for (const { labels, value } of all) {
|
||||
const labelStr = Object.entries(labels)
|
||||
.map(([k, v]) => `${k}="${v}"`)
|
||||
.join(',');
|
||||
const metricLine = labelStr
|
||||
? `${metric.name}{${labelStr}} ${value}`
|
||||
: `${metric.name} ${value}`;
|
||||
lines.push(metricLine);
|
||||
}
|
||||
} else if (metric instanceof Histogram) {
|
||||
const all = metric.getAll();
|
||||
for (const { labels, value } of all) {
|
||||
const labelStr = Object.entries(labels)
|
||||
.map(([k, v]) => `${k}="${v}"`)
|
||||
.join(',');
|
||||
const labelPrefix = labelStr ? `{${labelStr}}` : '';
|
||||
|
||||
// Export bucket counts
|
||||
for (const [bucket, count] of value.counts.entries()) {
|
||||
const bucketLabel = labelStr
|
||||
? `{${labelStr},le="${bucket}"}`
|
||||
: `{le="${bucket}"}`;
|
||||
lines.push(`${metric.name}_bucket${bucketLabel} ${count}`);
|
||||
}
|
||||
|
||||
// Export +Inf bucket
|
||||
const infLabel = labelStr ? `{${labelStr},le="+Inf"}` : `{le="+Inf"}`;
|
||||
lines.push(`${metric.name}_bucket${infLabel} ${value.count}`);
|
||||
|
||||
// Export sum and count
|
||||
lines.push(`${metric.name}_sum${labelPrefix} ${value.sum}`);
|
||||
lines.push(`${metric.name}_count${labelPrefix} ${value.count}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(''); // Empty line between metrics
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default metrics registry
|
||||
*/
|
||||
export const defaultRegistry = new MetricsRegistry();
|
||||
|
||||
/**
|
||||
* Metrics collector for Elasticsearch client
|
||||
*/
|
||||
export class MetricsCollector {
|
||||
public readonly registry: MetricsRegistry;
|
||||
|
||||
// Standard metrics
|
||||
public readonly requestsTotal: Counter;
|
||||
public readonly requestDuration: Histogram;
|
||||
public readonly requestErrors: Counter;
|
||||
public readonly activeConnections: Gauge;
|
||||
public readonly bulkOperations: Counter;
|
||||
public readonly bulkDocuments: Counter;
|
||||
public readonly retries: Counter;
|
||||
|
||||
constructor(registry: MetricsRegistry = defaultRegistry) {
|
||||
this.registry = registry;
|
||||
|
||||
// Initialize standard metrics
|
||||
this.requestsTotal = new Counter(
|
||||
'elasticsearch_requests_total',
|
||||
'Total number of Elasticsearch requests',
|
||||
['operation', 'index']
|
||||
);
|
||||
this.registry.register(this.requestsTotal);
|
||||
|
||||
this.requestDuration = new Histogram(
|
||||
'elasticsearch_request_duration_seconds',
|
||||
'Elasticsearch request duration in seconds',
|
||||
['operation', 'index']
|
||||
);
|
||||
this.registry.register(this.requestDuration);
|
||||
|
||||
this.requestErrors = new Counter(
|
||||
'elasticsearch_request_errors_total',
|
||||
'Total number of Elasticsearch request errors',
|
||||
['operation', 'index', 'error_code']
|
||||
);
|
||||
this.registry.register(this.requestErrors);
|
||||
|
||||
this.activeConnections = new Gauge(
|
||||
'elasticsearch_active_connections',
|
||||
'Number of active Elasticsearch connections'
|
||||
);
|
||||
this.registry.register(this.activeConnections);
|
||||
|
||||
this.bulkOperations = new Counter(
|
||||
'elasticsearch_bulk_operations_total',
|
||||
'Total number of bulk operations',
|
||||
['index']
|
||||
);
|
||||
this.registry.register(this.bulkOperations);
|
||||
|
||||
this.bulkDocuments = new Counter(
|
||||
'elasticsearch_bulk_documents_total',
|
||||
'Total number of documents in bulk operations',
|
||||
['index', 'status']
|
||||
);
|
||||
this.registry.register(this.bulkDocuments);
|
||||
|
||||
this.retries = new Counter(
|
||||
'elasticsearch_retries_total',
|
||||
'Total number of retry attempts',
|
||||
['operation']
|
||||
);
|
||||
this.registry.register(this.retries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom counter
|
||||
*/
|
||||
counter(name: string, help: string, labels?: string[]): Counter {
|
||||
const counter = new Counter(name, help, labels);
|
||||
this.registry.register(counter);
|
||||
return counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom gauge
|
||||
*/
|
||||
gauge(name: string, help: string, labels?: string[]): Gauge {
|
||||
const gauge = new Gauge(name, help, labels);
|
||||
this.registry.register(gauge);
|
||||
return gauge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom histogram
|
||||
*/
|
||||
histogram(name: string, help: string, labels?: string[], buckets?: number[]): Histogram {
|
||||
const histogram = new Histogram(name, help, labels, buckets);
|
||||
this.registry.register(histogram);
|
||||
return histogram;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all metrics in Prometheus format
|
||||
*/
|
||||
export(): string {
|
||||
return this.registry.export();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default metrics collector
|
||||
*/
|
||||
export const defaultMetricsCollector = new MetricsCollector();
|
||||
438
ts/core/observability/tracing.ts
Normal file
438
ts/core/observability/tracing.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Span attributes
|
||||
*/
|
||||
export type SpanAttributes = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
/**
|
||||
* Span status
|
||||
*/
|
||||
export enum SpanStatus {
|
||||
OK = 'OK',
|
||||
ERROR = 'ERROR',
|
||||
UNSET = 'UNSET',
|
||||
}
|
||||
|
||||
/**
|
||||
* Span context for distributed tracing
|
||||
*/
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags: number;
|
||||
traceState?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Span interface
|
||||
*/
|
||||
export interface Span {
|
||||
/** Span name */
|
||||
name: string;
|
||||
|
||||
/** Span start time */
|
||||
startTime: Date;
|
||||
|
||||
/** Span end time (if ended) */
|
||||
endTime?: Date;
|
||||
|
||||
/** Span status */
|
||||
status: SpanStatus;
|
||||
|
||||
/** Span attributes */
|
||||
attributes: SpanAttributes;
|
||||
|
||||
/** Span context */
|
||||
context: SpanContext;
|
||||
|
||||
/** Parent span ID */
|
||||
parentSpanId?: string;
|
||||
|
||||
/** Set an attribute */
|
||||
setAttribute(key: string, value: string | number | boolean): void;
|
||||
|
||||
/** Set multiple attributes */
|
||||
setAttributes(attributes: SpanAttributes): void;
|
||||
|
||||
/** Set span status */
|
||||
setStatus(status: SpanStatus, message?: string): void;
|
||||
|
||||
/** Add an event to the span */
|
||||
addEvent(name: string, attributes?: SpanAttributes): void;
|
||||
|
||||
/** End the span */
|
||||
end(): void;
|
||||
|
||||
/** Record an exception */
|
||||
recordException(exception: Error): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracer interface
|
||||
*/
|
||||
export interface Tracer {
|
||||
/** Start a new span */
|
||||
startSpan(name: string, attributes?: SpanAttributes): Span;
|
||||
|
||||
/** Get the active span */
|
||||
getActiveSpan(): Span | null;
|
||||
|
||||
/** Execute function within span context */
|
||||
withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random trace ID
|
||||
*/
|
||||
function generateTraceId(): string {
|
||||
return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random span ID
|
||||
*/
|
||||
function generateSpanId(): string {
|
||||
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic in-memory span implementation
|
||||
*/
|
||||
export class InMemorySpan implements Span {
|
||||
public name: string;
|
||||
public startTime: Date;
|
||||
public endTime?: Date;
|
||||
public status: SpanStatus = SpanStatus.UNSET;
|
||||
public attributes: SpanAttributes;
|
||||
public context: SpanContext;
|
||||
public parentSpanId?: string;
|
||||
public events: Array<{ name: string; timestamp: Date; attributes?: SpanAttributes }> = [];
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
attributes: SpanAttributes = {},
|
||||
parentContext?: SpanContext
|
||||
) {
|
||||
this.name = name;
|
||||
this.startTime = new Date();
|
||||
this.attributes = { ...attributes };
|
||||
|
||||
if (parentContext) {
|
||||
this.context = {
|
||||
traceId: parentContext.traceId,
|
||||
spanId: generateSpanId(),
|
||||
traceFlags: parentContext.traceFlags,
|
||||
traceState: parentContext.traceState,
|
||||
};
|
||||
this.parentSpanId = parentContext.spanId;
|
||||
} else {
|
||||
this.context = {
|
||||
traceId: generateTraceId(),
|
||||
spanId: generateSpanId(),
|
||||
traceFlags: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setAttribute(key: string, value: string | number | boolean): void {
|
||||
this.attributes[key] = value;
|
||||
}
|
||||
|
||||
setAttributes(attributes: SpanAttributes): void {
|
||||
Object.assign(this.attributes, attributes);
|
||||
}
|
||||
|
||||
setStatus(status: SpanStatus, message?: string): void {
|
||||
this.status = status;
|
||||
if (message) {
|
||||
this.setAttribute('status.message', message);
|
||||
}
|
||||
}
|
||||
|
||||
addEvent(name: string, attributes?: SpanAttributes): void {
|
||||
this.events.push({
|
||||
name,
|
||||
timestamp: new Date(),
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
|
||||
recordException(exception: Error): void {
|
||||
this.setStatus(SpanStatus.ERROR);
|
||||
this.setAttribute('exception.type', exception.name);
|
||||
this.setAttribute('exception.message', exception.message);
|
||||
if (exception.stack) {
|
||||
this.setAttribute('exception.stacktrace', exception.stack);
|
||||
}
|
||||
}
|
||||
|
||||
end(): void {
|
||||
if (!this.endTime) {
|
||||
this.endTime = new Date();
|
||||
|
||||
if (this.status === SpanStatus.UNSET) {
|
||||
this.status = SpanStatus.OK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duration in milliseconds
|
||||
*/
|
||||
getDuration(): number | null {
|
||||
if (!this.endTime) return null;
|
||||
return this.endTime.getTime() - this.startTime.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert span to JSON
|
||||
*/
|
||||
toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
name: this.name,
|
||||
traceId: this.context.traceId,
|
||||
spanId: this.context.spanId,
|
||||
parentSpanId: this.parentSpanId,
|
||||
startTime: this.startTime.toISOString(),
|
||||
endTime: this.endTime?.toISOString(),
|
||||
duration: this.getDuration(),
|
||||
status: this.status,
|
||||
attributes: this.attributes,
|
||||
events: this.events,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Span exporter interface
|
||||
*/
|
||||
export interface SpanExporter {
|
||||
export(spans: Span[]): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Console span exporter for debugging
|
||||
*/
|
||||
export class ConsoleSpanExporter implements SpanExporter {
|
||||
export(spans: Span[]): void {
|
||||
for (const span of spans) {
|
||||
if (span instanceof InMemorySpan) {
|
||||
console.log('[TRACE]', JSON.stringify(span.toJSON(), null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory tracer implementation
|
||||
*/
|
||||
export class InMemoryTracer implements Tracer {
|
||||
private activeSpan: Span | null = null;
|
||||
private spans: Span[] = [];
|
||||
private exporter: SpanExporter;
|
||||
|
||||
constructor(exporter: SpanExporter = new ConsoleSpanExporter()) {
|
||||
this.exporter = exporter;
|
||||
}
|
||||
|
||||
startSpan(name: string, attributes?: SpanAttributes): Span {
|
||||
const parentContext = this.activeSpan?.context;
|
||||
const span = new InMemorySpan(name, attributes, parentContext);
|
||||
this.spans.push(span);
|
||||
return span;
|
||||
}
|
||||
|
||||
getActiveSpan(): Span | null {
|
||||
return this.activeSpan;
|
||||
}
|
||||
|
||||
async withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T> {
|
||||
const span = this.startSpan(name);
|
||||
const previousActiveSpan = this.activeSpan;
|
||||
this.activeSpan = span;
|
||||
|
||||
try {
|
||||
const result = await fn(span);
|
||||
span.setStatus(SpanStatus.OK);
|
||||
return result;
|
||||
} catch (error) {
|
||||
span.recordException(error as Error);
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
this.activeSpan = previousActiveSpan;
|
||||
this.exportSpan(span);
|
||||
}
|
||||
}
|
||||
|
||||
private exportSpan(span: Span): void {
|
||||
try {
|
||||
const result = this.exporter.export([span]);
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch((err) => {
|
||||
console.error('Span export error:', err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Span export error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recorded spans
|
||||
*/
|
||||
getSpans(): Span[] {
|
||||
return [...this.spans];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all spans
|
||||
*/
|
||||
clear(): void {
|
||||
this.spans = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context carrier for propagation
|
||||
*/
|
||||
inject(carrier: Record<string, string>): void {
|
||||
if (this.activeSpan) {
|
||||
const { traceId, spanId, traceFlags } = this.activeSpan.context;
|
||||
carrier['traceparent'] = `00-${traceId}-${spanId}-${traceFlags.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract context from carrier
|
||||
*/
|
||||
extract(carrier: Record<string, string>): SpanContext | null {
|
||||
const traceparent = carrier['traceparent'];
|
||||
if (!traceparent) return null;
|
||||
|
||||
const parts = traceparent.split('-');
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
return {
|
||||
traceId: parts[1],
|
||||
spanId: parts[2],
|
||||
traceFlags: parseInt(parts[3], 16),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op tracer for when tracing is disabled
|
||||
*/
|
||||
class NoOpSpan implements Span {
|
||||
name = '';
|
||||
startTime = new Date();
|
||||
status = SpanStatus.UNSET;
|
||||
attributes = {};
|
||||
context: SpanContext = {
|
||||
traceId: '00000000000000000000000000000000',
|
||||
spanId: '0000000000000000',
|
||||
traceFlags: 0,
|
||||
};
|
||||
|
||||
setAttribute(): void {}
|
||||
setAttributes(): void {}
|
||||
setStatus(): void {}
|
||||
addEvent(): void {}
|
||||
end(): void {}
|
||||
recordException(): void {}
|
||||
}
|
||||
|
||||
class NoOpTracer implements Tracer {
|
||||
private noOpSpan = new NoOpSpan();
|
||||
|
||||
startSpan(): Span {
|
||||
return this.noOpSpan;
|
||||
}
|
||||
|
||||
getActiveSpan(): Span | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async withSpan<T>(_name: string, fn: (span: Span) => Promise<T>): Promise<T> {
|
||||
return fn(this.noOpSpan);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tracer instance
|
||||
*/
|
||||
export const defaultTracer: Tracer = new InMemoryTracer();
|
||||
|
||||
/**
|
||||
* No-op tracer instance (for performance-sensitive scenarios)
|
||||
*/
|
||||
export const noOpTracer: Tracer = new NoOpTracer();
|
||||
|
||||
/**
|
||||
* Tracing provider configuration
|
||||
*/
|
||||
export interface TracingConfig {
|
||||
enabled: boolean;
|
||||
exporter?: SpanExporter;
|
||||
serviceName?: string;
|
||||
serviceVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracing provider
|
||||
*/
|
||||
export class TracingProvider {
|
||||
private tracer: Tracer;
|
||||
private config: TracingConfig;
|
||||
|
||||
constructor(config: Partial<TracingConfig> = {}) {
|
||||
this.config = {
|
||||
enabled: config.enabled !== false,
|
||||
exporter: config.exporter,
|
||||
serviceName: config.serviceName || 'elasticsearch-client',
|
||||
serviceVersion: config.serviceVersion,
|
||||
};
|
||||
|
||||
this.tracer = this.config.enabled
|
||||
? new InMemoryTracer(this.config.exporter)
|
||||
: noOpTracer;
|
||||
}
|
||||
|
||||
getTracer(): Tracer {
|
||||
return this.tracer;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
createSpan(name: string, attributes?: SpanAttributes): Span {
|
||||
const span = this.tracer.startSpan(name, {
|
||||
...attributes,
|
||||
'service.name': this.config.serviceName,
|
||||
...(this.config.serviceVersion && { 'service.version': this.config.serviceVersion }),
|
||||
});
|
||||
return span;
|
||||
}
|
||||
|
||||
async withSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T> {
|
||||
return this.tracer.withSpan(name, fn);
|
||||
}
|
||||
|
||||
propagateContext(carrier: Record<string, string>): void {
|
||||
if (this.tracer instanceof InMemoryTracer) {
|
||||
this.tracer.inject(carrier);
|
||||
}
|
||||
}
|
||||
|
||||
extractContext(carrier: Record<string, string>): SpanContext | null {
|
||||
if (this.tracer instanceof InMemoryTracer) {
|
||||
return this.tracer.extract(carrier);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default tracing provider
|
||||
*/
|
||||
export const defaultTracingProvider = new TracingProvider();
|
||||
257
ts/core/plugins/built-in/cache-plugin.ts
Normal file
257
ts/core/plugins/built-in/cache-plugin.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Cache Plugin
|
||||
*
|
||||
* Caches GET request responses to reduce load on Elasticsearch
|
||||
*/
|
||||
|
||||
import { defaultLogger } from '../../observability/logger.js';
|
||||
import type { Plugin, PluginContext, PluginResponse, CachePluginConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Cache entry
|
||||
*/
|
||||
interface CacheEntry<T = unknown> {
|
||||
response: PluginResponse<T>;
|
||||
cachedAt: number;
|
||||
expiresAt: number;
|
||||
hits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<CachePluginConfig> = {
|
||||
enabled: true,
|
||||
maxEntries: 1000,
|
||||
defaultTTL: 60, // 60 seconds
|
||||
keyGenerator: (context: PluginContext) => {
|
||||
const query = context.request.querystring
|
||||
? JSON.stringify(context.request.querystring)
|
||||
: '';
|
||||
const body = context.request.body ? JSON.stringify(context.request.body) : '';
|
||||
return `${context.request.method}:${context.request.path}:${query}:${body}`;
|
||||
},
|
||||
methods: ['GET'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Create cache plugin
|
||||
*/
|
||||
export function createCachePlugin(config: CachePluginConfig = {}): Plugin {
|
||||
const pluginConfig: Required<CachePluginConfig> = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
keyGenerator: config.keyGenerator || DEFAULT_CONFIG.keyGenerator,
|
||||
};
|
||||
|
||||
const logger = defaultLogger;
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
let cacheHits = 0;
|
||||
let cacheMisses = 0;
|
||||
|
||||
/**
|
||||
* Get from cache
|
||||
*/
|
||||
function getFromCache<T>(key: string): PluginResponse<T> | null {
|
||||
const entry = cache.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
if (!entry) {
|
||||
cacheMisses++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
const now = Date.now();
|
||||
if (now >= entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
cacheMisses++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
entry.hits++;
|
||||
cacheHits++;
|
||||
|
||||
logger.debug('Cache hit', {
|
||||
key,
|
||||
age: now - entry.cachedAt,
|
||||
hits: entry.hits,
|
||||
});
|
||||
|
||||
return entry.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set in cache
|
||||
*/
|
||||
function setInCache<T>(key: string, response: PluginResponse<T>, ttl: number): void {
|
||||
// Check if cache is full
|
||||
if (cache.size >= pluginConfig.maxEntries && !cache.has(key)) {
|
||||
evictOldest();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
cache.set(key, {
|
||||
response,
|
||||
cachedAt: now,
|
||||
expiresAt: now + ttl * 1000,
|
||||
hits: 0,
|
||||
});
|
||||
|
||||
logger.debug('Cache set', { key, ttl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict oldest entry
|
||||
*/
|
||||
function evictOldest(): void {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.cachedAt < oldestTime) {
|
||||
oldestTime = entry.cachedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey) {
|
||||
cache.delete(oldestKey);
|
||||
logger.debug('Cache evicted', { key: oldestKey });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
*/
|
||||
function clearCache(): void {
|
||||
cache.clear();
|
||||
cacheHits = 0;
|
||||
cacheMisses = 0;
|
||||
logger.info('Cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean expired entries
|
||||
*/
|
||||
function cleanExpired(): void {
|
||||
const now = Date.now();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [key, entry] of cache) {
|
||||
if (now >= entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned > 0) {
|
||||
logger.debug('Cache cleaned', { expired: cleaned });
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic cleanup
|
||||
let cleanupTimer: NodeJS.Timeout;
|
||||
|
||||
return {
|
||||
name: 'cache',
|
||||
version: '1.0.0',
|
||||
priority: 50, // Execute in the middle
|
||||
|
||||
initialize: () => {
|
||||
// Start periodic cleanup
|
||||
cleanupTimer = setInterval(cleanExpired, 60000); // Every minute
|
||||
|
||||
logger.info('Cache plugin initialized', {
|
||||
maxEntries: pluginConfig.maxEntries,
|
||||
defaultTTL: pluginConfig.defaultTTL,
|
||||
methods: pluginConfig.methods,
|
||||
});
|
||||
},
|
||||
|
||||
beforeRequest: <T>(context: PluginContext): PluginContext | null => {
|
||||
if (!pluginConfig.enabled) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Only cache configured methods
|
||||
if (!pluginConfig.methods.includes(context.request.method)) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = pluginConfig.keyGenerator(context);
|
||||
|
||||
// Check cache
|
||||
const cachedResponse = getFromCache<T>(cacheKey);
|
||||
if (cachedResponse) {
|
||||
// Store cached response in shared data for afterResponse to use
|
||||
context.shared.set('cache_hit', true);
|
||||
context.shared.set('cached_response', cachedResponse);
|
||||
context.shared.set('cache_key', cacheKey);
|
||||
} else {
|
||||
context.shared.set('cache_hit', false);
|
||||
context.shared.set('cache_key', cacheKey);
|
||||
}
|
||||
|
||||
return context;
|
||||
},
|
||||
|
||||
afterResponse: <T>(context: PluginContext, response: PluginResponse<T>) => {
|
||||
if (!pluginConfig.enabled) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const cacheHit = context.shared.get('cache_hit');
|
||||
|
||||
// If it was a cache hit, return the cached response
|
||||
if (cacheHit) {
|
||||
return context.shared.get('cached_response') as PluginResponse<T>;
|
||||
}
|
||||
|
||||
// Otherwise, cache this response
|
||||
const cacheKey = context.shared.get('cache_key') as string;
|
||||
if (cacheKey && pluginConfig.methods.includes(context.request.method)) {
|
||||
// Only cache successful responses
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
setInCache(cacheKey, response, pluginConfig.defaultTTL);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
destroy: () => {
|
||||
if (cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
}
|
||||
|
||||
clearCache();
|
||||
|
||||
logger.info('Cache plugin destroyed', {
|
||||
totalHits: cacheHits,
|
||||
totalMisses: cacheMisses,
|
||||
hitRatio: cacheHits / (cacheHits + cacheMisses) || 0,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
export function getCacheStats(plugin: Plugin): {
|
||||
size: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
hitRatio: number;
|
||||
} | null {
|
||||
if (plugin.name !== 'cache') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// This would require exposing stats from the plugin
|
||||
// For now, return null
|
||||
return null;
|
||||
}
|
||||
164
ts/core/plugins/built-in/logging-plugin.ts
Normal file
164
ts/core/plugins/built-in/logging-plugin.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Logging Plugin
|
||||
*
|
||||
* Automatically logs requests, responses, and errors
|
||||
*/
|
||||
|
||||
import { defaultLogger } from '../../observability/logger.js';
|
||||
import type { Plugin, PluginContext, PluginResponse, LoggingPluginConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<LoggingPluginConfig> = {
|
||||
logRequests: true,
|
||||
logResponses: true,
|
||||
logErrors: true,
|
||||
logRequestBody: false,
|
||||
logResponseBody: false,
|
||||
maxBodySize: 1024, // 1KB
|
||||
sensitiveFields: ['password', 'token', 'secret', 'authorization', 'api_key'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Create logging plugin
|
||||
*/
|
||||
export function createLoggingPlugin(config: LoggingPluginConfig = {}): Plugin {
|
||||
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
const logger = defaultLogger;
|
||||
|
||||
return {
|
||||
name: 'logging',
|
||||
version: '1.0.0',
|
||||
priority: 10, // Execute early
|
||||
|
||||
beforeRequest: (context: PluginContext) => {
|
||||
if (!pluginConfig.logRequests) {
|
||||
return context;
|
||||
}
|
||||
|
||||
const logData: Record<string, unknown> = {
|
||||
requestId: context.request.requestId,
|
||||
method: context.request.method,
|
||||
path: context.request.path,
|
||||
};
|
||||
|
||||
// Add querystring if present
|
||||
if (context.request.querystring) {
|
||||
logData.querystring = context.request.querystring;
|
||||
}
|
||||
|
||||
// Add request body if enabled
|
||||
if (pluginConfig.logRequestBody && context.request.body) {
|
||||
const bodyStr = JSON.stringify(context.request.body);
|
||||
if (bodyStr.length <= pluginConfig.maxBodySize) {
|
||||
logData.body = sanitizeObject(context.request.body, pluginConfig.sensitiveFields);
|
||||
} else {
|
||||
logData.bodySize = bodyStr.length;
|
||||
logData.bodyTruncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Elasticsearch request', logData);
|
||||
|
||||
return context;
|
||||
},
|
||||
|
||||
afterResponse: <T>(context: PluginContext, response: PluginResponse<T>) => {
|
||||
if (!pluginConfig.logResponses) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const duration = Date.now() - context.request.startTime;
|
||||
|
||||
const logData: Record<string, unknown> = {
|
||||
requestId: context.request.requestId,
|
||||
method: context.request.method,
|
||||
path: context.request.path,
|
||||
statusCode: response.statusCode,
|
||||
duration,
|
||||
};
|
||||
|
||||
// Add warnings if present
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
logData.warnings = response.warnings;
|
||||
}
|
||||
|
||||
// Add response body if enabled
|
||||
if (pluginConfig.logResponseBody && response.body) {
|
||||
const bodyStr = JSON.stringify(response.body);
|
||||
if (bodyStr.length <= pluginConfig.maxBodySize) {
|
||||
logData.body = response.body;
|
||||
} else {
|
||||
logData.bodySize = bodyStr.length;
|
||||
logData.bodyTruncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Elasticsearch response', logData);
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
onError: (context) => {
|
||||
if (!pluginConfig.logErrors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const duration = Date.now() - context.request.startTime;
|
||||
|
||||
logger.error('Elasticsearch error', {
|
||||
requestId: context.request.requestId,
|
||||
method: context.request.method,
|
||||
path: context.request.path,
|
||||
duration,
|
||||
attempts: context.attempts,
|
||||
error: {
|
||||
name: context.error.name,
|
||||
message: context.error.message,
|
||||
stack: context.error.stack,
|
||||
},
|
||||
statusCode: context.response?.statusCode,
|
||||
});
|
||||
|
||||
// Don't handle error, just log it
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize object by removing sensitive fields
|
||||
*/
|
||||
function sanitizeObject(obj: unknown, sensitiveFields: string[]): unknown {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => sanitizeObject(item, sensitiveFields));
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
// Check if key is sensitive
|
||||
const isSensitive = sensitiveFields.some((field) => lowerKey.includes(field.toLowerCase()));
|
||||
|
||||
if (isSensitive) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
sanitized[key] = sanitizeObject(value, sensitiveFields);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
141
ts/core/plugins/built-in/metrics-plugin.ts
Normal file
141
ts/core/plugins/built-in/metrics-plugin.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Metrics Plugin
|
||||
*
|
||||
* Automatically collects metrics for requests and responses
|
||||
*/
|
||||
|
||||
import { defaultMetricsCollector } from '../../observability/metrics.js';
|
||||
import type { Plugin, PluginContext, PluginResponse, MetricsPluginConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<MetricsPluginConfig> = {
|
||||
enabled: true,
|
||||
prefix: 'elasticsearch',
|
||||
recordDuration: true,
|
||||
recordSize: true,
|
||||
recordResponseSize: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create metrics plugin
|
||||
*/
|
||||
export function createMetricsPlugin(config: MetricsPluginConfig = {}): Plugin {
|
||||
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
const metrics = defaultMetricsCollector;
|
||||
|
||||
return {
|
||||
name: 'metrics',
|
||||
version: '1.0.0',
|
||||
priority: 20, // Execute early, after logging
|
||||
|
||||
beforeRequest: (context: PluginContext) => {
|
||||
if (!pluginConfig.enabled) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Record request counter
|
||||
metrics.recordCounter(`${pluginConfig.prefix}.requests`, 1, {
|
||||
method: context.request.method,
|
||||
path: extractIndexFromPath(context.request.path),
|
||||
});
|
||||
|
||||
// Record request size if enabled
|
||||
if (pluginConfig.recordSize && context.request.body) {
|
||||
const size = Buffer.byteLength(JSON.stringify(context.request.body), 'utf8');
|
||||
metrics.recordHistogram(`${pluginConfig.prefix}.request.size`, size, {
|
||||
method: context.request.method,
|
||||
});
|
||||
}
|
||||
|
||||
return context;
|
||||
},
|
||||
|
||||
afterResponse: <T>(context: PluginContext, response: PluginResponse<T>) => {
|
||||
if (!pluginConfig.enabled) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const duration = Date.now() - context.request.startTime;
|
||||
|
||||
// Record request duration if enabled
|
||||
if (pluginConfig.recordDuration) {
|
||||
metrics.recordHistogram(`${pluginConfig.prefix}.request.duration`, duration, {
|
||||
method: context.request.method,
|
||||
path: extractIndexFromPath(context.request.path),
|
||||
status: response.statusCode.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Record response size if enabled
|
||||
if (pluginConfig.recordResponseSize && response.body) {
|
||||
const size = Buffer.byteLength(JSON.stringify(response.body), 'utf8');
|
||||
metrics.recordHistogram(`${pluginConfig.prefix}.response.size`, size, {
|
||||
method: context.request.method,
|
||||
status: response.statusCode.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Record success/failure
|
||||
const success = response.statusCode >= 200 && response.statusCode < 300;
|
||||
metrics.recordCounter(
|
||||
`${pluginConfig.prefix}.requests.${success ? 'success' : 'failure'}`,
|
||||
1,
|
||||
{
|
||||
method: context.request.method,
|
||||
status: response.statusCode.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
onError: (context) => {
|
||||
if (!pluginConfig.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const duration = Date.now() - context.request.startTime;
|
||||
|
||||
// Record error
|
||||
metrics.recordCounter(`${pluginConfig.prefix}.errors`, 1, {
|
||||
method: context.request.method,
|
||||
path: extractIndexFromPath(context.request.path),
|
||||
error: context.error.name,
|
||||
});
|
||||
|
||||
// Record error duration
|
||||
if (pluginConfig.recordDuration) {
|
||||
metrics.recordHistogram(`${pluginConfig.prefix}.error.duration`, duration, {
|
||||
method: context.request.method,
|
||||
error: context.error.name,
|
||||
});
|
||||
}
|
||||
|
||||
// Don't handle error
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract index name from path
|
||||
*/
|
||||
function extractIndexFromPath(path: string): string {
|
||||
// Remove leading slash
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
|
||||
// Split by slash and get first segment
|
||||
const segments = cleanPath.split('/');
|
||||
|
||||
// Common patterns:
|
||||
// /{index}/_search
|
||||
// /{index}/_doc/{id}
|
||||
// /_cat/indices
|
||||
if (segments[0].startsWith('_')) {
|
||||
return segments[0]; // API endpoint like _cat, _search
|
||||
}
|
||||
|
||||
return segments[0] || 'unknown';
|
||||
}
|
||||
166
ts/core/plugins/built-in/rate-limit-plugin.ts
Normal file
166
ts/core/plugins/built-in/rate-limit-plugin.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Rate Limit Plugin
|
||||
*
|
||||
* Limits request rate to prevent overwhelming Elasticsearch
|
||||
*/
|
||||
|
||||
import { defaultLogger } from '../../observability/logger.js';
|
||||
import type { Plugin, PluginContext, RateLimitPluginConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<RateLimitPluginConfig> = {
|
||||
maxRequestsPerSecond: 100,
|
||||
burstSize: 10,
|
||||
waitForSlot: true,
|
||||
maxWaitTime: 5000, // 5 seconds
|
||||
};
|
||||
|
||||
/**
|
||||
* Token bucket for rate limiting
|
||||
*/
|
||||
class TokenBucket {
|
||||
private tokens: number;
|
||||
private lastRefill: number;
|
||||
|
||||
constructor(
|
||||
private maxTokens: number,
|
||||
private refillRate: number // tokens per second
|
||||
) {
|
||||
this.tokens = maxTokens;
|
||||
this.lastRefill = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to consume a token
|
||||
*/
|
||||
async tryConsume(waitForToken: boolean, maxWaitTime: number): Promise<boolean> {
|
||||
this.refill();
|
||||
|
||||
// If we have tokens available, consume one
|
||||
if (this.tokens >= 1) {
|
||||
this.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not waiting, reject immediately
|
||||
if (!waitForToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate wait time for next token
|
||||
const waitTime = Math.min((1 / this.refillRate) * 1000, maxWaitTime);
|
||||
|
||||
// Wait for token to be available
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
|
||||
// Try again after waiting
|
||||
this.refill();
|
||||
|
||||
if (this.tokens >= 1) {
|
||||
this.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refill tokens based on time elapsed
|
||||
*/
|
||||
private refill(): void {
|
||||
const now = Date.now();
|
||||
const timePassed = (now - this.lastRefill) / 1000; // seconds
|
||||
const tokensToAdd = timePassed * this.refillRate;
|
||||
|
||||
this.tokens = Math.min(this.tokens + tokensToAdd, this.maxTokens);
|
||||
this.lastRefill = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current token count
|
||||
*/
|
||||
getTokens(): number {
|
||||
this.refill();
|
||||
return this.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset bucket
|
||||
*/
|
||||
reset(): void {
|
||||
this.tokens = this.maxTokens;
|
||||
this.lastRefill = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rate limit plugin
|
||||
*/
|
||||
export function createRateLimitPlugin(config: RateLimitPluginConfig = {}): Plugin {
|
||||
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
const logger = defaultLogger;
|
||||
|
||||
let tokenBucket: TokenBucket;
|
||||
let rejectedRequests = 0;
|
||||
let delayedRequests = 0;
|
||||
let totalWaitTime = 0;
|
||||
|
||||
return {
|
||||
name: 'rate-limit',
|
||||
version: '1.0.0',
|
||||
priority: 95, // Execute very late, right before request
|
||||
|
||||
initialize: () => {
|
||||
tokenBucket = new TokenBucket(
|
||||
pluginConfig.burstSize,
|
||||
pluginConfig.maxRequestsPerSecond
|
||||
);
|
||||
|
||||
logger.info('Rate limit plugin initialized', {
|
||||
maxRequestsPerSecond: pluginConfig.maxRequestsPerSecond,
|
||||
burstSize: pluginConfig.burstSize,
|
||||
waitForSlot: pluginConfig.waitForSlot,
|
||||
});
|
||||
},
|
||||
|
||||
beforeRequest: async (context: PluginContext) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Try to consume a token
|
||||
const acquired = await tokenBucket.tryConsume(
|
||||
pluginConfig.waitForSlot,
|
||||
pluginConfig.maxWaitTime
|
||||
);
|
||||
|
||||
if (!acquired) {
|
||||
rejectedRequests++;
|
||||
|
||||
logger.warn('Request rate limited', {
|
||||
requestId: context.request.requestId,
|
||||
rejectedCount: rejectedRequests,
|
||||
});
|
||||
|
||||
// Return null to cancel the request
|
||||
return null;
|
||||
}
|
||||
|
||||
const waitTime = Date.now() - startTime;
|
||||
|
||||
if (waitTime > 100) {
|
||||
// Only log if we actually waited
|
||||
delayedRequests++;
|
||||
totalWaitTime += waitTime;
|
||||
|
||||
logger.debug('Request delayed by rate limiter', {
|
||||
requestId: context.request.requestId,
|
||||
waitTime,
|
||||
availableTokens: tokenBucket.getTokens(),
|
||||
});
|
||||
}
|
||||
|
||||
return context;
|
||||
},
|
||||
};
|
||||
}
|
||||
140
ts/core/plugins/built-in/retry-plugin.ts
Normal file
140
ts/core/plugins/built-in/retry-plugin.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Retry Plugin
|
||||
*
|
||||
* Automatically retries failed requests with exponential backoff
|
||||
*/
|
||||
|
||||
import { defaultLogger } from '../../observability/logger.js';
|
||||
import type { Plugin, PluginErrorContext, RetryPluginConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<RetryPluginConfig> = {
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000, // 1 second
|
||||
maxDelay: 30000, // 30 seconds
|
||||
backoffMultiplier: 2,
|
||||
retryableStatusCodes: [429, 502, 503, 504],
|
||||
retryableErrors: [
|
||||
'ECONNRESET',
|
||||
'ENOTFOUND',
|
||||
'ESOCKETTIMEDOUT',
|
||||
'ETIMEDOUT',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'EPIPE',
|
||||
'EAI_AGAIN',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Create retry plugin
|
||||
*/
|
||||
export function createRetryPlugin(config: RetryPluginConfig = {}): Plugin {
|
||||
const pluginConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
const logger = defaultLogger;
|
||||
|
||||
/**
|
||||
* Check if error is retryable
|
||||
*/
|
||||
function isRetryable(context: PluginErrorContext): boolean {
|
||||
// Check if we've exceeded max retries
|
||||
if (context.attempts >= pluginConfig.maxRetries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check status code if response is available
|
||||
if (context.response) {
|
||||
return pluginConfig.retryableStatusCodes.includes(context.response.statusCode);
|
||||
}
|
||||
|
||||
// Check error code/type
|
||||
const errorCode = (context.error as any).code;
|
||||
const errorType = context.error.name;
|
||||
|
||||
if (errorCode && pluginConfig.retryableErrors.includes(errorCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pluginConfig.retryableErrors.includes(errorType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for timeout errors
|
||||
if (
|
||||
errorType === 'TimeoutError' ||
|
||||
context.error.message.toLowerCase().includes('timeout')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for connection errors
|
||||
if (
|
||||
errorType === 'ConnectionError' ||
|
||||
context.error.message.toLowerCase().includes('connection')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay with exponential backoff
|
||||
*/
|
||||
function calculateDelay(attempt: number): number {
|
||||
const delay = pluginConfig.initialDelay * Math.pow(pluginConfig.backoffMultiplier, attempt);
|
||||
return Math.min(delay, pluginConfig.maxDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified duration
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'retry',
|
||||
version: '1.0.0',
|
||||
priority: 90, // Execute late, close to the actual request
|
||||
|
||||
onError: async (context: PluginErrorContext) => {
|
||||
// Check if error is retryable
|
||||
if (!isRetryable(context)) {
|
||||
logger.debug('Error not retryable', {
|
||||
error: context.error.name,
|
||||
attempts: context.attempts,
|
||||
maxRetries: pluginConfig.maxRetries,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate delay
|
||||
const delay = calculateDelay(context.attempts);
|
||||
|
||||
logger.info('Retrying request', {
|
||||
requestId: context.request.requestId,
|
||||
attempt: context.attempts + 1,
|
||||
maxRetries: pluginConfig.maxRetries,
|
||||
delay,
|
||||
error: context.error.message,
|
||||
statusCode: context.response?.statusCode,
|
||||
});
|
||||
|
||||
// Wait before retrying
|
||||
await sleep(delay);
|
||||
|
||||
// Note: We don't actually retry the request here because we can't
|
||||
// access the client from the plugin. Instead, we return null to
|
||||
// indicate that the error was not handled, and the caller should
|
||||
// handle the retry logic.
|
||||
//
|
||||
// In a real implementation, you would integrate this with the
|
||||
// connection manager to actually retry the request.
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
34
ts/core/plugins/index.ts
Normal file
34
ts/core/plugins/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Plugin System Module
|
||||
*
|
||||
* Extensible request/response middleware
|
||||
*/
|
||||
|
||||
// Core plugin system
|
||||
export { PluginManager, createPluginManager } from './plugin-manager.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Plugin,
|
||||
PluginFactory,
|
||||
PluginContext,
|
||||
PluginResponse,
|
||||
PluginErrorContext,
|
||||
PluginStats,
|
||||
PluginManagerConfig,
|
||||
RequestModification,
|
||||
ResponseModification,
|
||||
// Built-in plugin configs
|
||||
RetryPluginConfig,
|
||||
CachePluginConfig,
|
||||
LoggingPluginConfig,
|
||||
MetricsPluginConfig,
|
||||
RateLimitPluginConfig,
|
||||
} from './types.js';
|
||||
|
||||
// Built-in plugins
|
||||
export { createLoggingPlugin } from './built-in/logging-plugin.js';
|
||||
export { createMetricsPlugin } from './built-in/metrics-plugin.js';
|
||||
export { createCachePlugin } from './built-in/cache-plugin.js';
|
||||
export { createRetryPlugin } from './built-in/retry-plugin.js';
|
||||
export { createRateLimitPlugin } from './built-in/rate-limit-plugin.js';
|
||||
426
ts/core/plugins/plugin-manager.ts
Normal file
426
ts/core/plugins/plugin-manager.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* 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 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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: 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<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: 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<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: 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<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);
|
||||
}
|
||||
337
ts/core/plugins/types.ts
Normal file
337
ts/core/plugins/types.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Plugin system types for extending client functionality
|
||||
*/
|
||||
|
||||
import type { Client } from '@elastic/elasticsearch';
|
||||
|
||||
/**
|
||||
* Plugin context passed to all plugin hooks
|
||||
*/
|
||||
export interface PluginContext {
|
||||
/** Elasticsearch client instance */
|
||||
client: Client;
|
||||
|
||||
/** Request metadata */
|
||||
request: {
|
||||
/** HTTP method */
|
||||
method: string;
|
||||
|
||||
/** Request path */
|
||||
path: string;
|
||||
|
||||
/** Request body */
|
||||
body?: unknown;
|
||||
|
||||
/** Query parameters */
|
||||
querystring?: Record<string, unknown>;
|
||||
|
||||
/** Request headers */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** Request ID for tracing */
|
||||
requestId: string;
|
||||
|
||||
/** Timestamp when request started */
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
/** Shared data between plugins */
|
||||
shared: Map<string, unknown>;
|
||||
|
||||
/** Plugin configuration */
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response object from Elasticsearch
|
||||
*/
|
||||
export interface PluginResponse<T = unknown> {
|
||||
/** Response body */
|
||||
body: T;
|
||||
|
||||
/** Response status code */
|
||||
statusCode: number;
|
||||
|
||||
/** Response headers */
|
||||
headers: Record<string, string>;
|
||||
|
||||
/** Response warnings */
|
||||
warnings?: string[];
|
||||
|
||||
/** Response metadata */
|
||||
meta?: {
|
||||
context: unknown;
|
||||
request: {
|
||||
params: unknown;
|
||||
options: unknown;
|
||||
id: number;
|
||||
};
|
||||
name: string;
|
||||
connection: unknown;
|
||||
attempts: number;
|
||||
aborted: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Error context for plugin error handling
|
||||
*/
|
||||
export interface PluginErrorContext extends PluginContext {
|
||||
/** The error that occurred */
|
||||
error: Error;
|
||||
|
||||
/** Number of retry attempts so far */
|
||||
attempts: number;
|
||||
|
||||
/** Response if available */
|
||||
response?: PluginResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin lifecycle hooks
|
||||
*/
|
||||
export interface Plugin {
|
||||
/** Plugin name (must be unique) */
|
||||
name: string;
|
||||
|
||||
/** Plugin version */
|
||||
version?: string;
|
||||
|
||||
/** Plugin priority (lower = earlier execution, default: 100) */
|
||||
priority?: number;
|
||||
|
||||
/** Plugin configuration */
|
||||
config?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Initialize plugin
|
||||
* Called once when plugin is registered
|
||||
*/
|
||||
initialize?: (client: Client, config: Record<string, unknown>) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Before request hook
|
||||
* Called before each request is sent
|
||||
* Can modify the request or cancel it
|
||||
*/
|
||||
beforeRequest?: (
|
||||
context: PluginContext
|
||||
) => Promise<PluginContext | null> | PluginContext | null;
|
||||
|
||||
/**
|
||||
* After response hook
|
||||
* Called after successful response
|
||||
* Can modify the response
|
||||
*/
|
||||
afterResponse?: <T>(
|
||||
context: PluginContext,
|
||||
response: PluginResponse<T>
|
||||
) => Promise<PluginResponse<T>> | PluginResponse<T>;
|
||||
|
||||
/**
|
||||
* On error hook
|
||||
* Called when request fails
|
||||
* Can handle error or rethrow
|
||||
*/
|
||||
onError?: (
|
||||
context: PluginErrorContext
|
||||
) => Promise<PluginResponse | null> | PluginResponse | null;
|
||||
|
||||
/**
|
||||
* Cleanup plugin
|
||||
* Called when plugin is unregistered or client is destroyed
|
||||
*/
|
||||
destroy?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin factory function
|
||||
*/
|
||||
export type PluginFactory = (config?: Record<string, unknown>) => Plugin;
|
||||
|
||||
/**
|
||||
* Request modification result
|
||||
*/
|
||||
export interface RequestModification {
|
||||
/** Modified request path */
|
||||
path?: string;
|
||||
|
||||
/** Modified request method */
|
||||
method?: string;
|
||||
|
||||
/** Modified request body */
|
||||
body?: unknown;
|
||||
|
||||
/** Modified querystring */
|
||||
querystring?: Record<string, unknown>;
|
||||
|
||||
/** Modified headers */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** Cancel this request */
|
||||
cancel?: boolean;
|
||||
|
||||
/** Skip remaining plugins */
|
||||
skipRemaining?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response modification result
|
||||
*/
|
||||
export interface ResponseModification<T = unknown> {
|
||||
/** Modified response body */
|
||||
body?: T;
|
||||
|
||||
/** Modified status code */
|
||||
statusCode?: number;
|
||||
|
||||
/** Modified headers */
|
||||
headers?: Record<string, string>;
|
||||
|
||||
/** Skip remaining plugins */
|
||||
skipRemaining?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin execution statistics
|
||||
*/
|
||||
export interface PluginStats {
|
||||
/** Plugin name */
|
||||
name: string;
|
||||
|
||||
/** Total times beforeRequest was called */
|
||||
beforeRequestCalls: number;
|
||||
|
||||
/** Total times afterResponse was called */
|
||||
afterResponseCalls: number;
|
||||
|
||||
/** Total times onError was called */
|
||||
onErrorCalls: number;
|
||||
|
||||
/** Average execution time for beforeRequest (ms) */
|
||||
avgBeforeRequestDuration: number;
|
||||
|
||||
/** Average execution time for afterResponse (ms) */
|
||||
avgAfterResponseDuration: number;
|
||||
|
||||
/** Average execution time for onError (ms) */
|
||||
avgOnErrorDuration: number;
|
||||
|
||||
/** Total errors in plugin execution */
|
||||
errors: number;
|
||||
|
||||
/** Last error message */
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin manager configuration
|
||||
*/
|
||||
export interface PluginManagerConfig {
|
||||
/** Enable plugin execution */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Maximum time a plugin hook can take (ms) */
|
||||
maxHookDuration?: number;
|
||||
|
||||
/** Whether to continue on plugin errors */
|
||||
continueOnError?: boolean;
|
||||
|
||||
/** Enable plugin statistics collection */
|
||||
collectStats?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in plugin configurations
|
||||
*/
|
||||
|
||||
export interface RetryPluginConfig {
|
||||
/** Maximum retry attempts */
|
||||
maxRetries?: number;
|
||||
|
||||
/** Initial retry delay (ms) */
|
||||
initialDelay?: number;
|
||||
|
||||
/** Maximum retry delay (ms) */
|
||||
maxDelay?: number;
|
||||
|
||||
/** Backoff multiplier */
|
||||
backoffMultiplier?: number;
|
||||
|
||||
/** HTTP status codes to retry */
|
||||
retryableStatusCodes?: number[];
|
||||
|
||||
/** Error types to retry */
|
||||
retryableErrors?: string[];
|
||||
}
|
||||
|
||||
export interface CachePluginConfig {
|
||||
/** Enable caching */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Maximum cache entries */
|
||||
maxEntries?: number;
|
||||
|
||||
/** Default TTL in seconds */
|
||||
defaultTTL?: number;
|
||||
|
||||
/** Cache key generator */
|
||||
keyGenerator?: (context: PluginContext) => string;
|
||||
|
||||
/** Methods to cache (default: ['GET']) */
|
||||
methods?: string[];
|
||||
}
|
||||
|
||||
export interface LoggingPluginConfig {
|
||||
/** Enable request logging */
|
||||
logRequests?: boolean;
|
||||
|
||||
/** Enable response logging */
|
||||
logResponses?: boolean;
|
||||
|
||||
/** Enable error logging */
|
||||
logErrors?: boolean;
|
||||
|
||||
/** Log request body */
|
||||
logRequestBody?: boolean;
|
||||
|
||||
/** Log response body */
|
||||
logResponseBody?: boolean;
|
||||
|
||||
/** Maximum body size to log (bytes) */
|
||||
maxBodySize?: number;
|
||||
|
||||
/** Sensitive fields to redact */
|
||||
sensitiveFields?: string[];
|
||||
}
|
||||
|
||||
export interface MetricsPluginConfig {
|
||||
/** Enable metrics collection */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Metrics prefix */
|
||||
prefix?: string;
|
||||
|
||||
/** Record request duration histogram */
|
||||
recordDuration?: boolean;
|
||||
|
||||
/** Record request size histogram */
|
||||
recordSize?: boolean;
|
||||
|
||||
/** Record response size histogram */
|
||||
recordResponseSize?: boolean;
|
||||
}
|
||||
|
||||
export interface RateLimitPluginConfig {
|
||||
/** Maximum requests per second */
|
||||
maxRequestsPerSecond?: number;
|
||||
|
||||
/** Burst size */
|
||||
burstSize?: number;
|
||||
|
||||
/** Wait for slot or reject immediately */
|
||||
waitForSlot?: boolean;
|
||||
|
||||
/** Maximum wait time (ms) */
|
||||
maxWaitTime?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user