feat(utils): add observability and resilience utilities
## Summary This PR adds a comprehensive set of observability and resilience utilities ported from RSocket, with all documentation translated to English. ### Changes - **Structured Logger** (`structured-logger.ts`) - ILogger-compatible structured logging with context and metadata - **Baileys Logger** (`baileys-logger.ts`) - Pino-compatible logger adapter for Baileys integration - **Trace Context** (`trace-context.ts`) - Distributed tracing with AsyncLocalStorage, span tracking, and header propagation - **Prometheus Metrics** (`prometheus-metrics.ts`) - Counter, Gauge, Histogram, and Summary metrics for monitoring - **Cache Utils** (`cache-utils.ts`) - LRU cache with TTL, hit/miss metrics, and multi-level cache support - **Circuit Breaker** (`circuit-breaker.ts`) - Enhanced with: - Sliding window failure tracking - Volume threshold configuration - Factory functions for WhatsApp-specific use cases (PreKey, Connection, Message) - **Retry Utils** (`retry-utils.ts`) - Exponential backoff, jitter, Fibonacci strategies with cancellation support - **Event Stream** (`baileys-event-stream.ts`) - Event streaming with backpressure and dead letter queue ### Technical Details - All comments translated from Portuguese to English - TypeScript strict mode compatible - ESM module exports - Factory functions for common circuit breaker patterns ### Stats - 9 files changed - 852 insertions(+) - 601 deletions(-)
This commit is contained in:
@@ -0,0 +1,741 @@
|
||||
/**
|
||||
* Baileys Event Stream Management
|
||||
*
|
||||
* Provides:
|
||||
* - Event buffering with backpressure
|
||||
* - Event transformation and filtering
|
||||
* - Priority queues for events
|
||||
* - Batch processing
|
||||
* - Dead letter queue for failed events
|
||||
* - Event replay
|
||||
* - Logging and metrics integration
|
||||
*
|
||||
* @module Utils/baileys-event-stream
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { metrics } from './prometheus-metrics.js'
|
||||
import type { BaileysLogCategory } from './baileys-logger.js'
|
||||
|
||||
/**
|
||||
* Baileys event types
|
||||
*/
|
||||
export type BaileysEventType =
|
||||
| 'connection.update'
|
||||
| 'creds.update'
|
||||
| 'messaging-history.set'
|
||||
| 'chats.set'
|
||||
| 'contacts.set'
|
||||
| 'messages.upsert'
|
||||
| 'messages.update'
|
||||
| 'messages.delete'
|
||||
| 'messages.reaction'
|
||||
| 'message-receipt.update'
|
||||
| 'groups.upsert'
|
||||
| 'groups.update'
|
||||
| 'group-participants.update'
|
||||
| 'presence.update'
|
||||
| 'chats.update'
|
||||
| 'chats.delete'
|
||||
| 'labels.edit'
|
||||
| 'labels.association'
|
||||
| 'call'
|
||||
| 'blocklist.set'
|
||||
| 'blocklist.update'
|
||||
| string // For custom events
|
||||
|
||||
/**
|
||||
* Event priority
|
||||
*/
|
||||
export type EventPriority = 'critical' | 'high' | 'normal' | 'low'
|
||||
|
||||
/**
|
||||
* Numeric priority values
|
||||
*/
|
||||
const PRIORITY_VALUES: Record<EventPriority, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream event
|
||||
*/
|
||||
export interface StreamEvent<T = unknown> {
|
||||
id: string
|
||||
type: BaileysEventType
|
||||
data: T
|
||||
timestamp: number
|
||||
priority: EventPriority
|
||||
category: BaileysLogCategory
|
||||
metadata?: Record<string, unknown>
|
||||
retryCount?: number
|
||||
originalTimestamp?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Stream options
|
||||
*/
|
||||
export interface EventStreamOptions {
|
||||
/** Maximum buffer size (default: 10000) */
|
||||
maxBufferSize?: number
|
||||
/** Whether to apply backpressure when buffer is full */
|
||||
enableBackpressure?: boolean
|
||||
/** High water mark limit for backpressure */
|
||||
highWaterMark?: number
|
||||
/** Low water mark limit to resume */
|
||||
lowWaterMark?: number
|
||||
/** Batch size for processing */
|
||||
batchSize?: number
|
||||
/** Flush interval in ms (0 = disabled) */
|
||||
flushInterval?: number
|
||||
/** Maximum retries for failed events */
|
||||
maxRetries?: number
|
||||
/** Dead letter queue size */
|
||||
deadLetterQueueSize?: number
|
||||
/** Whether to collect metrics */
|
||||
collectMetrics?: boolean
|
||||
/** Stream name for metrics */
|
||||
streamName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler
|
||||
*/
|
||||
export type EventHandler<T = unknown> = (event: StreamEvent<T>) => void | Promise<void>
|
||||
|
||||
/**
|
||||
* Event filter
|
||||
*/
|
||||
export type EventFilter<T = unknown> = (event: StreamEvent<T>) => boolean
|
||||
|
||||
/**
|
||||
* Event transformer
|
||||
*/
|
||||
export type EventTransformer<T = unknown, R = unknown> = (event: StreamEvent<T>) => StreamEvent<R>
|
||||
|
||||
/**
|
||||
* Batch processing result
|
||||
*/
|
||||
export interface BatchResult {
|
||||
processed: number
|
||||
failed: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream statistics
|
||||
*/
|
||||
export interface EventStreamStats {
|
||||
bufferSize: number
|
||||
totalReceived: number
|
||||
totalProcessed: number
|
||||
totalFailed: number
|
||||
totalDropped: number
|
||||
deadLetterQueueSize: number
|
||||
isBackpressured: boolean
|
||||
lastEventTimestamp?: number
|
||||
eventsByType: Record<string, number>
|
||||
eventsByPriority: Record<EventPriority, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* Event type to category mapping
|
||||
*/
|
||||
const EVENT_CATEGORY_MAP: Record<string, BaileysLogCategory> = {
|
||||
'connection.update': 'connection',
|
||||
'creds.update': 'auth',
|
||||
'messaging-history.set': 'sync',
|
||||
'chats.set': 'sync',
|
||||
'contacts.set': 'sync',
|
||||
'messages.upsert': 'message',
|
||||
'messages.update': 'message',
|
||||
'messages.delete': 'message',
|
||||
'messages.reaction': 'message',
|
||||
'message-receipt.update': 'message',
|
||||
'groups.upsert': 'group',
|
||||
'groups.update': 'group',
|
||||
'group-participants.update': 'group',
|
||||
'presence.update': 'presence',
|
||||
'chats.update': 'message',
|
||||
'chats.delete': 'message',
|
||||
'call': 'call',
|
||||
'blocklist.set': 'sync',
|
||||
'blocklist.update': 'sync',
|
||||
}
|
||||
|
||||
/**
|
||||
* Default priority by event type
|
||||
*/
|
||||
const EVENT_PRIORITY_MAP: Partial<Record<BaileysEventType, EventPriority>> = {
|
||||
'connection.update': 'critical',
|
||||
'creds.update': 'critical',
|
||||
'messages.upsert': 'high',
|
||||
'messages.update': 'high',
|
||||
'call': 'high',
|
||||
'presence.update': 'low',
|
||||
'messaging-history.set': 'normal',
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique event ID
|
||||
*/
|
||||
function generateEventId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Event Stream class
|
||||
*/
|
||||
export class BaileysEventStream extends EventEmitter {
|
||||
private buffer: StreamEvent[] = []
|
||||
private handlers: Map<BaileysEventType | '*', Set<EventHandler>> = new Map()
|
||||
private filters: EventFilter[] = []
|
||||
private transformers: EventTransformer[] = []
|
||||
private deadLetterQueue: StreamEvent[] = []
|
||||
private options: Required<EventStreamOptions>
|
||||
private stats: EventStreamStats
|
||||
private isProcessing = false
|
||||
private flushTimer?: ReturnType<typeof setInterval>
|
||||
private paused = false
|
||||
|
||||
constructor(options: EventStreamOptions = {}) {
|
||||
super()
|
||||
|
||||
this.options = {
|
||||
maxBufferSize: options.maxBufferSize ?? 10000,
|
||||
enableBackpressure: options.enableBackpressure ?? true,
|
||||
highWaterMark: options.highWaterMark ?? 8000,
|
||||
lowWaterMark: options.lowWaterMark ?? 2000,
|
||||
batchSize: options.batchSize ?? 100,
|
||||
flushInterval: options.flushInterval ?? 0,
|
||||
maxRetries: options.maxRetries ?? 3,
|
||||
deadLetterQueueSize: options.deadLetterQueueSize ?? 1000,
|
||||
collectMetrics: options.collectMetrics ?? true,
|
||||
streamName: options.streamName ?? 'baileys',
|
||||
}
|
||||
|
||||
this.stats = this.createInitialStats()
|
||||
|
||||
// Start periodic flush if configured
|
||||
if (this.options.flushInterval > 0) {
|
||||
this.flushTimer = setInterval(() => this.flush(), this.options.flushInterval)
|
||||
}
|
||||
}
|
||||
|
||||
private createInitialStats(): EventStreamStats {
|
||||
return {
|
||||
bufferSize: 0,
|
||||
totalReceived: 0,
|
||||
totalProcessed: 0,
|
||||
totalFailed: 0,
|
||||
totalDropped: 0,
|
||||
deadLetterQueueSize: 0,
|
||||
isBackpressured: false,
|
||||
eventsByType: {},
|
||||
eventsByPriority: {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
normal: 0,
|
||||
low: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event to stream
|
||||
*/
|
||||
push<T>(type: BaileysEventType, data: T, options?: { priority?: EventPriority; metadata?: Record<string, unknown> }): boolean {
|
||||
// Check backpressure
|
||||
if (this.options.enableBackpressure && this.buffer.length >= this.options.highWaterMark) {
|
||||
this.stats.isBackpressured = true
|
||||
this.emit('backpressure', { bufferSize: this.buffer.length })
|
||||
|
||||
if (this.buffer.length >= this.options.maxBufferSize) {
|
||||
this.stats.totalDropped++
|
||||
this.emit('dropped', { type, reason: 'buffer_full' })
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.errors.inc({ category: 'event_stream', code: 'dropped' })
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const event: StreamEvent<T> = {
|
||||
id: generateEventId(),
|
||||
type,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
priority: options?.priority || EVENT_PRIORITY_MAP[type] || 'normal',
|
||||
category: EVENT_CATEGORY_MAP[type] || 'unknown',
|
||||
metadata: options?.metadata,
|
||||
retryCount: 0,
|
||||
}
|
||||
|
||||
// Apply transformers
|
||||
let transformedEvent: StreamEvent = event
|
||||
for (const transformer of this.transformers) {
|
||||
transformedEvent = transformer(transformedEvent)
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
for (const filter of this.filters) {
|
||||
if (!filter(transformedEvent)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Add to buffer at correct position (by priority)
|
||||
this.insertByPriority(transformedEvent)
|
||||
|
||||
// Update statistics
|
||||
this.stats.totalReceived++
|
||||
this.stats.bufferSize = this.buffer.length
|
||||
this.stats.lastEventTimestamp = Date.now()
|
||||
this.stats.eventsByType[type] = (this.stats.eventsByType[type] || 0) + 1
|
||||
this.stats.eventsByPriority[event.priority]++
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.socketEvents.inc({ event: type })
|
||||
}
|
||||
|
||||
this.emit('event', transformedEvent)
|
||||
|
||||
// Process if not paused
|
||||
if (!this.paused && !this.isProcessing) {
|
||||
this.processNext()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert event in buffer by priority
|
||||
*/
|
||||
private insertByPriority(event: StreamEvent): void {
|
||||
const eventPriorityValue = PRIORITY_VALUES[event.priority]
|
||||
|
||||
// Find correct position
|
||||
let insertIndex = this.buffer.length
|
||||
for (let i = 0; i < this.buffer.length; i++) {
|
||||
if (PRIORITY_VALUES[this.buffer[i].priority] > eventPriorityValue) {
|
||||
insertIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.buffer.splice(insertIndex, 0, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register handler for event type
|
||||
*/
|
||||
on<T = unknown>(event: BaileysEventType | '*', handler: EventHandler<T>): this {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set())
|
||||
}
|
||||
this.handlers.get(event)!.add(handler as EventHandler)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove handler
|
||||
*/
|
||||
off(event: BaileysEventType | '*', handler: EventHandler): this {
|
||||
const handlers = this.handlers.get(event)
|
||||
if (handlers) {
|
||||
handlers.delete(handler)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register single-use handler
|
||||
*/
|
||||
once<T = unknown>(event: BaileysEventType, handler: EventHandler<T>): this {
|
||||
const wrappedHandler: EventHandler<T> = (e) => {
|
||||
this.off(event, wrappedHandler as EventHandler)
|
||||
return handler(e)
|
||||
}
|
||||
return this.on(event, wrappedHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add filter
|
||||
*/
|
||||
addFilter(filter: EventFilter): this {
|
||||
this.filters.push(filter)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove filter
|
||||
*/
|
||||
removeFilter(filter: EventFilter): this {
|
||||
const index = this.filters.indexOf(filter)
|
||||
if (index !== -1) {
|
||||
this.filters.splice(index, 1)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add transformer
|
||||
*/
|
||||
addTransformer(transformer: EventTransformer): this {
|
||||
this.transformers.push(transformer)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Process next events
|
||||
*/
|
||||
private async processNext(): Promise<void> {
|
||||
if (this.isProcessing || this.paused || this.buffer.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
|
||||
try {
|
||||
// Get batch of events
|
||||
const batch = this.buffer.splice(0, this.options.batchSize)
|
||||
this.stats.bufferSize = this.buffer.length
|
||||
|
||||
// Check if exited backpressure
|
||||
if (this.stats.isBackpressured && this.buffer.length <= this.options.lowWaterMark) {
|
||||
this.stats.isBackpressured = false
|
||||
this.emit('drain')
|
||||
}
|
||||
|
||||
// Process batch
|
||||
const startTime = Date.now()
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
|
||||
for (const event of batch) {
|
||||
try {
|
||||
await this.processEvent(event)
|
||||
processed++
|
||||
this.stats.totalProcessed++
|
||||
} catch (error) {
|
||||
failed++
|
||||
this.stats.totalFailed++
|
||||
await this.handleFailedEvent(event, error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
this.emit('batch-processed', { processed, failed, duration } as BatchResult)
|
||||
|
||||
// Continue processing if there are more
|
||||
if (this.buffer.length > 0) {
|
||||
setImmediate(() => this.processNext())
|
||||
}
|
||||
} finally {
|
||||
this.isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single event
|
||||
*/
|
||||
private async processEvent(event: StreamEvent): Promise<void> {
|
||||
// Type-specific handlers
|
||||
const typeHandlers = this.handlers.get(event.type)
|
||||
if (typeHandlers) {
|
||||
for (const handler of typeHandlers) {
|
||||
await handler(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Global handlers
|
||||
const globalHandlers = this.handlers.get('*')
|
||||
if (globalHandlers) {
|
||||
for (const handler of globalHandlers) {
|
||||
await handler(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle failed event
|
||||
*/
|
||||
private async handleFailedEvent(event: StreamEvent, error: Error): Promise<void> {
|
||||
event.retryCount = (event.retryCount || 0) + 1
|
||||
|
||||
if (event.retryCount <= this.options.maxRetries) {
|
||||
// Re-add to buffer for retry
|
||||
event.originalTimestamp = event.originalTimestamp || event.timestamp
|
||||
event.timestamp = Date.now()
|
||||
this.buffer.push(event)
|
||||
this.stats.bufferSize = this.buffer.length
|
||||
|
||||
this.emit('retry', { event, error, attempt: event.retryCount })
|
||||
} else {
|
||||
// Send to dead letter queue
|
||||
this.addToDeadLetterQueue(event, error)
|
||||
}
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.errors.inc({ category: 'event_stream', code: 'processing_failed' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event to dead letter queue
|
||||
*/
|
||||
private addToDeadLetterQueue(event: StreamEvent, error: Error): void {
|
||||
const dlqEvent = {
|
||||
...event,
|
||||
metadata: {
|
||||
...event.metadata,
|
||||
error: error.message,
|
||||
errorStack: error.stack,
|
||||
movedToDlqAt: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
this.deadLetterQueue.push(dlqEvent)
|
||||
|
||||
// Limit DLQ size
|
||||
while (this.deadLetterQueue.length > this.options.deadLetterQueueSize) {
|
||||
this.deadLetterQueue.shift()
|
||||
}
|
||||
|
||||
this.stats.deadLetterQueueSize = this.deadLetterQueue.length
|
||||
this.emit('dead-letter', dlqEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force flush the buffer
|
||||
*/
|
||||
async flush(): Promise<BatchResult> {
|
||||
const startTime = Date.now()
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
|
||||
while (this.buffer.length > 0 && !this.paused) {
|
||||
const batch = this.buffer.splice(0, this.options.batchSize)
|
||||
|
||||
for (const event of batch) {
|
||||
try {
|
||||
await this.processEvent(event)
|
||||
processed++
|
||||
this.stats.totalProcessed++
|
||||
} catch (error) {
|
||||
failed++
|
||||
this.stats.totalFailed++
|
||||
await this.handleFailedEvent(event, error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.stats.bufferSize = this.buffer.length
|
||||
|
||||
return {
|
||||
processed,
|
||||
failed,
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause processing
|
||||
*/
|
||||
pause(): void {
|
||||
this.paused = true
|
||||
this.emit('pause')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume processing
|
||||
*/
|
||||
resume(): void {
|
||||
this.paused = false
|
||||
this.emit('resume')
|
||||
this.processNext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if paused
|
||||
*/
|
||||
isPaused(): boolean {
|
||||
return this.paused
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the buffer
|
||||
*/
|
||||
clear(): void {
|
||||
this.buffer = []
|
||||
this.stats.bufferSize = 0
|
||||
this.emit('clear')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return dead letter queue events
|
||||
*/
|
||||
getDeadLetterQueue(): StreamEvent[] {
|
||||
return [...this.deadLetterQueue]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear dead letter queue
|
||||
*/
|
||||
clearDeadLetterQueue(): void {
|
||||
this.deadLetterQueue = []
|
||||
this.stats.deadLetterQueueSize = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay dead letter queue events
|
||||
*/
|
||||
async replayDeadLetterQueue(): Promise<BatchResult> {
|
||||
const events = this.deadLetterQueue.splice(0)
|
||||
this.stats.deadLetterQueueSize = 0
|
||||
|
||||
let processed = 0
|
||||
let failed = 0
|
||||
const startTime = Date.now()
|
||||
|
||||
for (const event of events) {
|
||||
// Reset retry count
|
||||
event.retryCount = 0
|
||||
delete event.metadata?.error
|
||||
delete event.metadata?.errorStack
|
||||
delete event.metadata?.movedToDlqAt
|
||||
|
||||
try {
|
||||
await this.processEvent(event)
|
||||
processed++
|
||||
} catch (error) {
|
||||
failed++
|
||||
this.addToDeadLetterQueue(event, error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
processed,
|
||||
failed,
|
||||
duration: Date.now() - startTime,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return statistics
|
||||
*/
|
||||
getStats(): EventStreamStats {
|
||||
return { ...this.stats }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset statistics
|
||||
*/
|
||||
resetStats(): void {
|
||||
this.stats = this.createInitialStats()
|
||||
this.stats.bufferSize = this.buffer.length
|
||||
this.stats.deadLetterQueueSize = this.deadLetterQueue.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy and clean up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer)
|
||||
}
|
||||
this.buffer = []
|
||||
this.deadLetterQueue = []
|
||||
this.handlers.clear()
|
||||
this.filters = []
|
||||
this.transformers = []
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create event stream
|
||||
*/
|
||||
export function createEventStream(options?: EventStreamOptions): BaileysEventStream {
|
||||
return new BaileysEventStream(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-defined filters
|
||||
*/
|
||||
export const eventFilters = {
|
||||
/** Filter by event type */
|
||||
byType:
|
||||
(...types: BaileysEventType[]): EventFilter =>
|
||||
(event) =>
|
||||
types.includes(event.type),
|
||||
|
||||
/** Filter by category */
|
||||
byCategory:
|
||||
(...categories: BaileysLogCategory[]): EventFilter =>
|
||||
(event) =>
|
||||
categories.includes(event.category),
|
||||
|
||||
/** Filter by minimum priority */
|
||||
byMinPriority:
|
||||
(minPriority: EventPriority): EventFilter =>
|
||||
(event) =>
|
||||
PRIORITY_VALUES[event.priority] <= PRIORITY_VALUES[minPriority],
|
||||
|
||||
/** Filter recent events (within ms) */
|
||||
recentOnly:
|
||||
(maxAgeMs: number): EventFilter =>
|
||||
(event) =>
|
||||
Date.now() - event.timestamp <= maxAgeMs,
|
||||
|
||||
/** Combine filters with AND */
|
||||
and:
|
||||
(...filters: EventFilter[]): EventFilter =>
|
||||
(event) =>
|
||||
filters.every((f) => f(event)),
|
||||
|
||||
/** Combine filters with OR */
|
||||
or:
|
||||
(...filters: EventFilter[]): EventFilter =>
|
||||
(event) =>
|
||||
filters.some((f) => f(event)),
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-defined transformers
|
||||
*/
|
||||
export const eventTransformers = {
|
||||
/** Add processing timestamp */
|
||||
addProcessingTimestamp: (): EventTransformer => (event) => ({
|
||||
...event,
|
||||
metadata: {
|
||||
...event.metadata,
|
||||
processingTimestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
|
||||
/** Add trace ID */
|
||||
addTraceId:
|
||||
(traceIdGenerator: () => string): EventTransformer =>
|
||||
(event) => ({
|
||||
...event,
|
||||
metadata: {
|
||||
...event.metadata,
|
||||
traceId: traceIdGenerator(),
|
||||
},
|
||||
}),
|
||||
|
||||
/** Elevate priority based on condition */
|
||||
elevatepriorityIf:
|
||||
(condition: (event: StreamEvent) => boolean, newPriority: EventPriority): EventTransformer =>
|
||||
(event) =>
|
||||
condition(event)
|
||||
? { ...event, priority: newPriority }
|
||||
: event,
|
||||
}
|
||||
|
||||
export default BaileysEventStream
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Custom Logger for Baileys/WhatsApp
|
||||
*
|
||||
* Provides:
|
||||
* - Pre-configured logger for Baileys/WhatsApp context
|
||||
* - Event categorization by type (connection, message, media, etc.)
|
||||
* - Specific filters to reduce noise
|
||||
* - WhatsApp event metrics
|
||||
* - Optimized formatting for debugging
|
||||
*
|
||||
* @module Utils/baileys-logger
|
||||
*/
|
||||
|
||||
import type { ILogger } from './logger.js'
|
||||
import { StructuredLogger, createStructuredLogger, type LogLevel, type LogEntry } from './structured-logger.js'
|
||||
|
||||
/**
|
||||
* Baileys-specific log categories
|
||||
*/
|
||||
export type BaileysLogCategory =
|
||||
| 'connection' // WebSocket connection events
|
||||
| 'auth' // Authentication and QR code
|
||||
| 'message' // Message send/receive
|
||||
| 'media' // Media upload/download
|
||||
| 'group' // Group operations
|
||||
| 'presence' // Presence status
|
||||
| 'call' // Voice/video calls
|
||||
| 'sync' // Data synchronization
|
||||
| 'encryption' // Encryption operations
|
||||
| 'retry' // Operation retries
|
||||
| 'socket' // Low-level socket events
|
||||
| 'binary' // Binary encoding/decoding
|
||||
| 'unknown' // Unknown category
|
||||
|
||||
/**
|
||||
* Baileys Logger configuration
|
||||
*/
|
||||
export interface BaileysLoggerConfig {
|
||||
/** Default log level */
|
||||
level: LogLevel
|
||||
/** Categories to ignore */
|
||||
ignoredCategories?: BaileysLogCategory[]
|
||||
/** Categories with elevated log level (always debug) */
|
||||
verboseCategories?: BaileysLogCategory[]
|
||||
/** Whether to log message payloads (may be sensitive) */
|
||||
logMessagePayloads?: boolean
|
||||
/** Whether to log binary data in hex */
|
||||
logBinaryData?: boolean
|
||||
/** Instance identifier prefix */
|
||||
instanceId?: string
|
||||
/** Handler for specific events */
|
||||
eventHandler?: (category: BaileysLogCategory, entry: LogEntry) => void
|
||||
/** Size limit for logged payloads (bytes) */
|
||||
maxPayloadSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Baileys-specific metrics
|
||||
*/
|
||||
export interface BaileysLoggerMetrics {
|
||||
connectionAttempts: number
|
||||
connectionSuccesses: number
|
||||
connectionFailures: number
|
||||
messagesSent: number
|
||||
messagesReceived: number
|
||||
mediaUploads: number
|
||||
mediaDownloads: number
|
||||
retryAttempts: number
|
||||
encryptionOperations: number
|
||||
errorsByCategory: Record<BaileysLogCategory, number>
|
||||
lastConnectionTime?: string
|
||||
lastMessageTime?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns to detect log category
|
||||
*/
|
||||
const CATEGORY_PATTERNS: Array<{ pattern: RegExp; category: BaileysLogCategory }> = [
|
||||
{ pattern: /connect|disconnect|socket|ws|websocket|open|close/i, category: 'connection' },
|
||||
{ pattern: /auth|qr|pairing|login|logout|creds/i, category: 'auth' },
|
||||
{ pattern: /message|msg|chat|text|send|recv|read|receipt/i, category: 'message' },
|
||||
{ pattern: /media|image|video|audio|document|sticker|upload|download/i, category: 'media' },
|
||||
{ pattern: /group|participant|admin|subject|invite/i, category: 'group' },
|
||||
{ pattern: /presence|online|offline|typing|available/i, category: 'presence' },
|
||||
{ pattern: /call|voice|video|ring/i, category: 'call' },
|
||||
{ pattern: /sync|history|initial|full/i, category: 'sync' },
|
||||
{ pattern: /encrypt|decrypt|signal|key|cipher/i, category: 'encryption' },
|
||||
{ pattern: /retry|attempt|backoff|reconnect/i, category: 'retry' },
|
||||
{ pattern: /binary|encode|decode|proto|buffer/i, category: 'binary' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Custom logger for Baileys
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const logger = createBaileysLogger({
|
||||
* level: 'debug',
|
||||
* instanceId: 'session-1'
|
||||
* })
|
||||
*
|
||||
* logger.logConnection('connected', { duration: 1500 })
|
||||
* logger.logMessage('send', 'text', 'user@s.whatsapp.net')
|
||||
* ```
|
||||
*/
|
||||
export class BaileysLogger implements ILogger {
|
||||
private structuredLogger: StructuredLogger
|
||||
private config: Required<BaileysLoggerConfig>
|
||||
private metrics: BaileysLoggerMetrics
|
||||
private childContext: Record<string, unknown> = {}
|
||||
|
||||
constructor(config: Partial<BaileysLoggerConfig> = {}) {
|
||||
this.config = {
|
||||
level: config.level || 'info',
|
||||
ignoredCategories: config.ignoredCategories || [],
|
||||
verboseCategories: config.verboseCategories || [],
|
||||
logMessagePayloads: config.logMessagePayloads ?? false,
|
||||
logBinaryData: config.logBinaryData ?? false,
|
||||
instanceId: config.instanceId || this.generateInstanceId(),
|
||||
eventHandler: config.eventHandler || (() => {}),
|
||||
maxPayloadSize: config.maxPayloadSize || 1024,
|
||||
}
|
||||
|
||||
this.structuredLogger = createStructuredLogger({
|
||||
level: this.config.level,
|
||||
name: `baileys:${this.config.instanceId}`,
|
||||
jsonFormat: process.env.NODE_ENV === 'production',
|
||||
redactFields: ['password', 'token', 'secret', 'key', 'authKey', 'macKey'],
|
||||
})
|
||||
|
||||
this.metrics = this.createInitialMetrics()
|
||||
}
|
||||
|
||||
private generateInstanceId(): string {
|
||||
return Math.random().toString(36).substring(2, 8)
|
||||
}
|
||||
|
||||
private createInitialMetrics(): BaileysLoggerMetrics {
|
||||
return {
|
||||
connectionAttempts: 0,
|
||||
connectionSuccesses: 0,
|
||||
connectionFailures: 0,
|
||||
messagesSent: 0,
|
||||
messagesReceived: 0,
|
||||
mediaUploads: 0,
|
||||
mediaDownloads: 0,
|
||||
retryAttempts: 0,
|
||||
encryptionOperations: 0,
|
||||
errorsByCategory: {
|
||||
connection: 0,
|
||||
auth: 0,
|
||||
message: 0,
|
||||
media: 0,
|
||||
group: 0,
|
||||
presence: 0,
|
||||
call: 0,
|
||||
sync: 0,
|
||||
encryption: 0,
|
||||
retry: 0,
|
||||
socket: 0,
|
||||
binary: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
get level(): string {
|
||||
return this.config.level
|
||||
}
|
||||
|
||||
set level(newLevel: string) {
|
||||
this.config.level = newLevel as LogLevel
|
||||
this.structuredLogger.level = newLevel
|
||||
}
|
||||
|
||||
/**
|
||||
* Create child logger with additional context
|
||||
*/
|
||||
child(obj: Record<string, unknown>): BaileysLogger {
|
||||
const childLogger = new BaileysLogger(this.config)
|
||||
childLogger.childContext = { ...this.childContext, ...obj }
|
||||
return childLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect log category based on content
|
||||
*/
|
||||
private detectCategory(obj: unknown, msg?: string): BaileysLogCategory {
|
||||
const searchText = [
|
||||
msg || '',
|
||||
typeof obj === 'string' ? obj : '',
|
||||
typeof obj === 'object' && obj !== null ? JSON.stringify(obj) : '',
|
||||
].join(' ')
|
||||
|
||||
for (const { pattern, category } of CATEGORY_PATTERNS) {
|
||||
if (pattern.test(searchText)) {
|
||||
return category
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if category should be logged
|
||||
*/
|
||||
private shouldLogCategory(category: BaileysLogCategory, _level: LogLevel): boolean {
|
||||
if (this.config.ignoredCategories.includes(category)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verbose categories always log at debug or higher
|
||||
if (this.config.verboseCategories.includes(category)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize message payload
|
||||
*/
|
||||
private sanitizePayload(obj: unknown): unknown {
|
||||
if (!this.config.logMessagePayloads) {
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const sanitized = { ...(obj as Record<string, unknown>) }
|
||||
|
||||
// Remove sensitive message fields
|
||||
const sensitiveFields = ['body', 'text', 'content', 'caption', 'payload', 'data']
|
||||
for (const field of sensitiveFields) {
|
||||
if (field in sanitized) {
|
||||
const value = sanitized[field]
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
sanitized[field] = `[${value.length} chars]`
|
||||
} else if (Buffer.isBuffer(value)) {
|
||||
sanitized[field] = `[Buffer: ${value.length} bytes]`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Limit payload size
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const str = JSON.stringify(obj)
|
||||
if (str.length > this.config.maxPayloadSize) {
|
||||
return {
|
||||
_truncated: true,
|
||||
_originalSize: str.length,
|
||||
_preview: str.substring(0, 200) + '...',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metrics based on log
|
||||
*/
|
||||
private updateMetrics(category: BaileysLogCategory, level: LogLevel, obj: unknown): void {
|
||||
const objStr = typeof obj === 'object' ? JSON.stringify(obj) : String(obj)
|
||||
|
||||
switch (category) {
|
||||
case 'connection':
|
||||
if (/attempt|trying|connecting/i.test(objStr)) {
|
||||
this.metrics.connectionAttempts++
|
||||
} else if (/success|connected|open/i.test(objStr)) {
|
||||
this.metrics.connectionSuccesses++
|
||||
this.metrics.lastConnectionTime = new Date().toISOString()
|
||||
} else if (/fail|error|close/i.test(objStr)) {
|
||||
this.metrics.connectionFailures++
|
||||
}
|
||||
break
|
||||
|
||||
case 'message':
|
||||
if (/send|sent|outgoing/i.test(objStr)) {
|
||||
this.metrics.messagesSent++
|
||||
this.metrics.lastMessageTime = new Date().toISOString()
|
||||
} else if (/recv|received|incoming/i.test(objStr)) {
|
||||
this.metrics.messagesReceived++
|
||||
this.metrics.lastMessageTime = new Date().toISOString()
|
||||
}
|
||||
break
|
||||
|
||||
case 'media':
|
||||
if (/upload/i.test(objStr)) {
|
||||
this.metrics.mediaUploads++
|
||||
} else if (/download/i.test(objStr)) {
|
||||
this.metrics.mediaDownloads++
|
||||
}
|
||||
break
|
||||
|
||||
case 'retry':
|
||||
this.metrics.retryAttempts++
|
||||
break
|
||||
|
||||
case 'encryption':
|
||||
this.metrics.encryptionOperations++
|
||||
break
|
||||
}
|
||||
|
||||
if (level === 'error' || level === 'fatal') {
|
||||
this.metrics.errorsByCategory[category]++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main log method
|
||||
*/
|
||||
private log(level: LogLevel, obj: unknown, msg?: string): void {
|
||||
const category = this.detectCategory(obj, msg)
|
||||
|
||||
if (!this.shouldLogCategory(category, level)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update metrics
|
||||
this.updateMetrics(category, level, obj)
|
||||
|
||||
// Sanitize payload
|
||||
const sanitizedObj = this.sanitizePayload(obj)
|
||||
|
||||
// Add Baileys context
|
||||
const enrichedObj = {
|
||||
category,
|
||||
instanceId: this.config.instanceId,
|
||||
...this.childContext,
|
||||
...(typeof sanitizedObj === 'object' && sanitizedObj !== null ? sanitizedObj : { value: sanitizedObj }),
|
||||
}
|
||||
|
||||
// Structured log
|
||||
this.structuredLogger[level](enrichedObj, msg)
|
||||
|
||||
// Event handler
|
||||
if (this.config.eventHandler) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
levelValue: 0,
|
||||
message: msg || '',
|
||||
name: `baileys:${this.config.instanceId}`,
|
||||
data: enrichedObj,
|
||||
}
|
||||
this.config.eventHandler(category, entry)
|
||||
}
|
||||
}
|
||||
|
||||
trace(obj: unknown, msg?: string): void {
|
||||
this.log('trace', obj, msg)
|
||||
}
|
||||
|
||||
debug(obj: unknown, msg?: string): void {
|
||||
this.log('debug', obj, msg)
|
||||
}
|
||||
|
||||
info(obj: unknown, msg?: string): void {
|
||||
this.log('info', obj, msg)
|
||||
}
|
||||
|
||||
warn(obj: unknown, msg?: string): void {
|
||||
this.log('warn', obj, msg)
|
||||
}
|
||||
|
||||
error(obj: unknown, msg?: string): void {
|
||||
this.log('error', obj, msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log connection-specific event
|
||||
*/
|
||||
logConnection(event: 'connecting' | 'connected' | 'disconnected' | 'error', details?: Record<string, unknown>): void {
|
||||
const level: LogLevel = event === 'error' ? 'error' : event === 'disconnected' ? 'warn' : 'info'
|
||||
this.log(level, { event, ...details, category: 'connection' }, `Connection ${event}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message-specific event
|
||||
*/
|
||||
logMessage(
|
||||
direction: 'send' | 'receive',
|
||||
messageType: string,
|
||||
jid: string,
|
||||
details?: Record<string, unknown>
|
||||
): void {
|
||||
const sanitizedJid = this.sanitizeJid(jid)
|
||||
this.log(
|
||||
'info',
|
||||
{
|
||||
direction,
|
||||
messageType,
|
||||
jid: sanitizedJid,
|
||||
...details,
|
||||
category: 'message',
|
||||
},
|
||||
`Message ${direction}: ${messageType}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log media-specific event
|
||||
*/
|
||||
logMedia(
|
||||
operation: 'upload' | 'download',
|
||||
mediaType: string,
|
||||
size: number,
|
||||
details?: Record<string, unknown>
|
||||
): void {
|
||||
this.log(
|
||||
'info',
|
||||
{
|
||||
operation,
|
||||
mediaType,
|
||||
sizeBytes: size,
|
||||
sizeFormatted: this.formatBytes(size),
|
||||
...details,
|
||||
category: 'media',
|
||||
},
|
||||
`Media ${operation}: ${mediaType}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize JID for logging (mask part of number)
|
||||
*/
|
||||
private sanitizeJid(jid: string): string {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// In production, mask part of the number
|
||||
const parts = jid.split('@')
|
||||
if (parts.length === 2 && parts[0].length > 4) {
|
||||
return `${parts[0].substring(0, 4)}****@${parts[1]}`
|
||||
}
|
||||
}
|
||||
return jid
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes for human readability
|
||||
*/
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logger metrics
|
||||
*/
|
||||
getMetrics(): BaileysLoggerMetrics {
|
||||
return { ...this.metrics }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get internal structured logger metrics
|
||||
*/
|
||||
getStructuredMetrics() {
|
||||
return this.structuredLogger.getMetrics()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset metrics
|
||||
*/
|
||||
resetMetrics(): void {
|
||||
this.metrics = this.createInitialMetrics()
|
||||
this.structuredLogger.resetMetrics()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instance ID
|
||||
*/
|
||||
getInstanceId(): string {
|
||||
return this.config.instanceId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create Baileys Logger
|
||||
*/
|
||||
export function createBaileysLogger(config?: Partial<BaileysLoggerConfig>): BaileysLogger {
|
||||
return new BaileysLogger(config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Baileys logger singleton
|
||||
*/
|
||||
let defaultBaileysLogger: BaileysLogger | null = null
|
||||
|
||||
export function getDefaultBaileysLogger(): BaileysLogger {
|
||||
if (!defaultBaileysLogger) {
|
||||
defaultBaileysLogger = createBaileysLogger({
|
||||
level: 'info',
|
||||
})
|
||||
}
|
||||
return defaultBaileysLogger
|
||||
}
|
||||
|
||||
export function setDefaultBaileysLogger(logger: BaileysLogger): void {
|
||||
defaultBaileysLogger = logger
|
||||
}
|
||||
|
||||
export default BaileysLogger
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Smart Cache System
|
||||
*
|
||||
* Provides:
|
||||
* - In-memory cache with configurable TTL
|
||||
* - Automatic and manual invalidation
|
||||
* - Hit/miss metrics
|
||||
* - LRU (Least Recently Used) strategy
|
||||
* - Distributed cache (prepared for Redis)
|
||||
* - Namespace for isolation
|
||||
* - Customizable serialization
|
||||
*
|
||||
* @module Utils/cache-utils
|
||||
*/
|
||||
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { metrics } from './prometheus-metrics.js'
|
||||
|
||||
/**
|
||||
* Cache configuration options
|
||||
*/
|
||||
export interface CacheOptions<V> {
|
||||
/** Time to live in ms (default: 5 minutes) */
|
||||
ttl?: number
|
||||
/** Maximum cache size (default: 1000) */
|
||||
maxSize?: number
|
||||
/** Function to calculate item size */
|
||||
sizeCalculation?: (value: V) => number
|
||||
/** Whether to update TTL on access */
|
||||
updateAgeOnGet?: boolean
|
||||
/** Namespace for isolation */
|
||||
namespace?: string
|
||||
/** Callback when item expires */
|
||||
onExpire?: (key: string, value: V) => void
|
||||
/** Whether to collect metrics */
|
||||
collectMetrics?: boolean
|
||||
/** Cache name for metrics */
|
||||
metricName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache statistics
|
||||
*/
|
||||
export interface CacheStats {
|
||||
hits: number
|
||||
misses: number
|
||||
size: number
|
||||
maxSize: number
|
||||
hitRate: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache item with metadata
|
||||
*/
|
||||
export interface CacheItem<V> {
|
||||
value: V
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
accessCount: number
|
||||
lastAccess: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache operation result
|
||||
*/
|
||||
export interface CacheResult<V> {
|
||||
value: V | undefined
|
||||
hit: boolean
|
||||
expired?: boolean
|
||||
key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Cache class
|
||||
*/
|
||||
export class Cache<V = unknown> {
|
||||
private cache: LRUCache<string, CacheItem<V>>
|
||||
private options: Required<CacheOptions<V>>
|
||||
private stats: { hits: number; misses: number }
|
||||
private namespace: string
|
||||
|
||||
constructor(options: CacheOptions<V> = {}) {
|
||||
this.options = {
|
||||
ttl: options.ttl ?? 5 * 60 * 1000, // 5 minutos
|
||||
maxSize: options.maxSize ?? 1000,
|
||||
sizeCalculation: options.sizeCalculation ?? (() => 1),
|
||||
updateAgeOnGet: options.updateAgeOnGet ?? false,
|
||||
namespace: options.namespace ?? 'default',
|
||||
onExpire: options.onExpire ?? (() => {}),
|
||||
collectMetrics: options.collectMetrics ?? true,
|
||||
metricName: options.metricName ?? 'cache',
|
||||
}
|
||||
|
||||
this.namespace = this.options.namespace
|
||||
this.stats = { hits: 0, misses: 0 }
|
||||
|
||||
this.cache = new LRUCache<string, CacheItem<V>>({
|
||||
max: this.options.maxSize,
|
||||
ttl: this.options.ttl,
|
||||
updateAgeOnGet: this.options.updateAgeOnGet,
|
||||
sizeCalculation: (item) => this.options.sizeCalculation(item.value),
|
||||
dispose: (value, key) => {
|
||||
this.options.onExpire(key, value.value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value from cache
|
||||
*/
|
||||
get(key: string): V | undefined {
|
||||
const fullKey = this.getFullKey(key)
|
||||
const item = this.cache.get(fullKey)
|
||||
|
||||
if (item) {
|
||||
this.stats.hits++
|
||||
item.accessCount++
|
||||
item.lastAccess = Date.now()
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.cacheHits.inc({ cache: this.options.metricName })
|
||||
}
|
||||
|
||||
return item.value
|
||||
}
|
||||
|
||||
this.stats.misses++
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.cacheMisses.inc({ cache: this.options.metricName })
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value with detailed result
|
||||
*/
|
||||
getWithResult(key: string): CacheResult<V> {
|
||||
const fullKey = this.getFullKey(key)
|
||||
const item = this.cache.get(fullKey)
|
||||
|
||||
if (item) {
|
||||
this.stats.hits++
|
||||
item.accessCount++
|
||||
item.lastAccess = Date.now()
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.cacheHits.inc({ cache: this.options.metricName })
|
||||
}
|
||||
|
||||
return {
|
||||
value: item.value,
|
||||
hit: true,
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
this.stats.misses++
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.cacheMisses.inc({ cache: this.options.metricName })
|
||||
}
|
||||
|
||||
return {
|
||||
value: undefined,
|
||||
hit: false,
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value in cache
|
||||
*/
|
||||
set(key: string, value: V, ttl?: number): void {
|
||||
const fullKey = this.getFullKey(key)
|
||||
const now = Date.now()
|
||||
const itemTtl = ttl ?? this.options.ttl
|
||||
|
||||
const item: CacheItem<V> = {
|
||||
value,
|
||||
createdAt: now,
|
||||
expiresAt: now + itemTtl,
|
||||
accessCount: 0,
|
||||
lastAccess: now,
|
||||
}
|
||||
|
||||
this.cache.set(fullKey, item, { ttl: itemTtl })
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
const fullKey = this.getFullKey(key)
|
||||
return this.cache.has(fullKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from cache
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
const fullKey = this.getFullKey(key)
|
||||
const result = this.cache.delete(fullKey)
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire cache
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
this.stats = { hits: 0, misses: 0 }
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.cacheSize.set({ cache: this.options.metricName }, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or set value (cache-aside pattern)
|
||||
*/
|
||||
async getOrSet(key: string, factory: () => V | Promise<V>, ttl?: number): Promise<V> {
|
||||
const existing = this.get(key)
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const value = await factory()
|
||||
this.set(key, value, ttl)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or set value synchronously
|
||||
*/
|
||||
getOrSetSync(key: string, factory: () => V, ttl?: number): V {
|
||||
const existing = this.get(key)
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const value = factory()
|
||||
this.set(key, value, ttl)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate items by pattern
|
||||
*/
|
||||
invalidateByPattern(pattern: RegExp): number {
|
||||
let count = 0
|
||||
const prefix = `${this.namespace}:`
|
||||
|
||||
for (const key of this.cache.keys()) {
|
||||
const shortKey = key.startsWith(prefix) ? key.slice(prefix.length) : key
|
||||
if (pattern.test(shortKey)) {
|
||||
this.cache.delete(key)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.cacheSize.set({ cache: this.options.metricName }, this.cache.size)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate items by prefix
|
||||
*/
|
||||
invalidateByPrefix(prefix: string): number {
|
||||
return this.invalidateByPattern(new RegExp(`^${prefix}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return cache statistics
|
||||
*/
|
||||
getStats(): CacheStats {
|
||||
const total = this.stats.hits + this.stats.misses
|
||||
return {
|
||||
hits: this.stats.hits,
|
||||
misses: this.stats.misses,
|
||||
size: this.cache.size,
|
||||
maxSize: this.options.maxSize,
|
||||
hitRate: total > 0 ? this.stats.hits / total : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current size
|
||||
*/
|
||||
get size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all keys
|
||||
*/
|
||||
keys(): string[] {
|
||||
const prefix = `${this.namespace}:`
|
||||
return Array.from(this.cache.keys()).map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all values
|
||||
*/
|
||||
values(): V[] {
|
||||
return Array.from(this.cache.values()).map((item) => item.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all items with metadata
|
||||
*/
|
||||
entries(): Array<{ key: string; item: CacheItem<V> }> {
|
||||
const prefix = `${this.namespace}:`
|
||||
return Array.from(this.cache.entries()).map(([key, item]) => ({
|
||||
key: key.startsWith(prefix) ? key.slice(prefix.length) : key,
|
||||
item,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update TTL of an item
|
||||
*/
|
||||
touch(key: string, ttl?: number): boolean {
|
||||
const fullKey = this.getFullKey(key)
|
||||
const item = this.cache.get(fullKey)
|
||||
|
||||
if (!item) {
|
||||
return false
|
||||
}
|
||||
|
||||
const newTtl = ttl ?? this.options.ttl
|
||||
item.expiresAt = Date.now() + newTtl
|
||||
this.cache.set(fullKey, item, { ttl: newTtl })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expired item (if still in memory)
|
||||
*/
|
||||
peek(key: string): V | undefined {
|
||||
const fullKey = this.getFullKey(key)
|
||||
const item = this.cache.peek(fullKey)
|
||||
return item?.value
|
||||
}
|
||||
|
||||
private getFullKey(key: string): string {
|
||||
return `${this.namespace}:${key}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create typed cache
|
||||
*/
|
||||
export function createCache<V>(options?: CacheOptions<V>): Cache<V> {
|
||||
return new Cache<V>(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-level cache (L1: memory, L2: external)
|
||||
*/
|
||||
export class MultiLevelCache<V> {
|
||||
private l1: Cache<V>
|
||||
private l2?: {
|
||||
get: (key: string) => Promise<V | undefined>
|
||||
set: (key: string, value: V, ttl?: number) => Promise<void>
|
||||
delete: (key: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
constructor(
|
||||
l1Options: CacheOptions<V>,
|
||||
l2?: {
|
||||
get: (key: string) => Promise<V | undefined>
|
||||
set: (key: string, value: V, ttl?: number) => Promise<void>
|
||||
delete: (key: string) => Promise<boolean>
|
||||
}
|
||||
) {
|
||||
this.l1 = new Cache<V>(l1Options)
|
||||
this.l2 = l2
|
||||
}
|
||||
|
||||
async get(key: string): Promise<V | undefined> {
|
||||
// Try L1 first
|
||||
const l1Value = this.l1.get(key)
|
||||
if (l1Value !== undefined) {
|
||||
return l1Value
|
||||
}
|
||||
|
||||
// Try L2 if available
|
||||
if (this.l2) {
|
||||
const l2Value = await this.l2.get(key)
|
||||
if (l2Value !== undefined) {
|
||||
// Promote to L1
|
||||
this.l1.set(key, l2Value)
|
||||
return l2Value
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async set(key: string, value: V, ttl?: number): Promise<void> {
|
||||
this.l1.set(key, value, ttl)
|
||||
|
||||
if (this.l2) {
|
||||
await this.l2.set(key, value, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
const l1Result = this.l1.delete(key)
|
||||
let l2Result = false
|
||||
|
||||
if (this.l2) {
|
||||
l2Result = await this.l2.delete(key)
|
||||
}
|
||||
|
||||
return l1Result || l2Result
|
||||
}
|
||||
|
||||
getL1(): Cache<V> {
|
||||
return this.l1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator to cache method result
|
||||
*/
|
||||
export function cached<V>(options: CacheOptions<V> & { keyGenerator?: (...args: unknown[]) => string } = {}) {
|
||||
const cache = new Cache<V>(options)
|
||||
const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args))
|
||||
|
||||
return function (
|
||||
_target: unknown,
|
||||
propertyKey: string,
|
||||
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => V | Promise<V>>
|
||||
) {
|
||||
const originalMethod = descriptor.value
|
||||
if (!originalMethod) return descriptor
|
||||
|
||||
descriptor.value = async function (...args: unknown[]): Promise<V> {
|
||||
const key = `${propertyKey}:${keyGenerator(...args)}`
|
||||
|
||||
const cachedValue = cache.get(key)
|
||||
if (cachedValue !== undefined) {
|
||||
return cachedValue
|
||||
}
|
||||
|
||||
const result = await originalMethod.apply(this, args)
|
||||
cache.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for function with cache
|
||||
*/
|
||||
export function withCache<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
options: CacheOptions<ReturnType<T>> & { keyGenerator?: (...args: Parameters<T>) => string } = {}
|
||||
): T {
|
||||
const cache = new Cache<ReturnType<T>>(options)
|
||||
const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args))
|
||||
|
||||
return ((...args: Parameters<T>): ReturnType<T> => {
|
||||
const key = keyGenerator(...args)
|
||||
|
||||
const cachedValue = cache.get(key)
|
||||
if (cachedValue !== undefined) {
|
||||
return cachedValue as ReturnType<T>
|
||||
}
|
||||
|
||||
const result = fn(...args) as ReturnType<T>
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then((value) => {
|
||||
cache.set(key, value as ReturnType<T>)
|
||||
return value
|
||||
}) as ReturnType<T>
|
||||
}
|
||||
|
||||
cache.set(key, result)
|
||||
return result
|
||||
}) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Global singleton cache by namespace
|
||||
*/
|
||||
const globalCaches: Map<string, Cache<unknown>> = new Map()
|
||||
|
||||
export function getGlobalCache<V>(namespace: string, options?: CacheOptions<V>): Cache<V> {
|
||||
if (!globalCaches.has(namespace)) {
|
||||
globalCaches.set(namespace, new Cache<V>({ ...options, namespace }))
|
||||
}
|
||||
return globalCaches.get(namespace) as Cache<V>
|
||||
}
|
||||
|
||||
export function clearGlobalCaches(): void {
|
||||
for (const cache of globalCaches.values()) {
|
||||
cache.clear()
|
||||
}
|
||||
globalCaches.clear()
|
||||
}
|
||||
|
||||
export default Cache
|
||||
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* Circuit Breaker Pattern Implementation
|
||||
*
|
||||
* Provides protection against cascading failures by monitoring operation outcomes
|
||||
* and temporarily blocking requests when failure thresholds are exceeded.
|
||||
*
|
||||
* States:
|
||||
* - CLOSED: Normal operation, requests pass through
|
||||
* - OPEN: Failures exceeded threshold, requests are blocked
|
||||
* - HALF_OPEN: Testing recovery, limited requests allowed
|
||||
*
|
||||
* @module Utils/circuit-breaker
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { metrics } from './prometheus-metrics.js'
|
||||
|
||||
/**
|
||||
* Circuit breaker operational states
|
||||
*/
|
||||
export type CircuitState = 'closed' | 'open' | 'half-open'
|
||||
|
||||
/**
|
||||
* Failure record with timestamp for sliding window tracking
|
||||
*/
|
||||
export interface FailureRecord {
|
||||
timestamp: number
|
||||
error: Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker configuration options
|
||||
*/
|
||||
export interface CircuitBreakerOptions {
|
||||
/** Unique identifier for this circuit breaker (used in metrics/logging) */
|
||||
name: string
|
||||
/** Number of failures within the window to trigger OPEN state (default: 5) */
|
||||
failureThreshold?: number
|
||||
/** Time window in ms for counting failures (default: 60000) */
|
||||
failureWindow?: number
|
||||
/** Number of successes required in HALF_OPEN to return to CLOSED (default: 2) */
|
||||
successThreshold?: number
|
||||
/** Time in ms to wait before transitioning from OPEN to HALF_OPEN (default: 30000) */
|
||||
resetTimeout?: number
|
||||
/** Timeout for individual operations in ms (default: 10000) */
|
||||
timeout?: number
|
||||
/** Minimum number of requests before circuit can trip (default: 5) */
|
||||
volumeThreshold?: number
|
||||
/** Predicate to determine if an error should count as a failure */
|
||||
shouldCountError?: (error: Error) => boolean
|
||||
/** Whether to collect Prometheus metrics (default: true) */
|
||||
collectMetrics?: boolean
|
||||
/** Fallback function when circuit is OPEN */
|
||||
fallback?: <T>() => T | Promise<T>
|
||||
/** Callback when state changes */
|
||||
onStateChange?: (from: CircuitState, to: CircuitState) => void
|
||||
/** Callback on failure */
|
||||
onFailure?: (error: Error) => void
|
||||
/** Callback on success */
|
||||
onSuccess?: () => void
|
||||
/** Callback when circuit opens */
|
||||
onOpen?: () => void
|
||||
/** Callback when circuit closes */
|
||||
onClose?: () => void
|
||||
/** Callback when circuit enters half-open */
|
||||
onHalfOpen?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker statistics
|
||||
*/
|
||||
export interface CircuitBreakerStats {
|
||||
state: CircuitState
|
||||
failures: number
|
||||
successes: number
|
||||
consecutiveFailures: number
|
||||
consecutiveSuccesses: number
|
||||
totalCalls: number
|
||||
totalFailures: number
|
||||
totalSuccesses: number
|
||||
totalRejected: number
|
||||
failureRate: number
|
||||
lastFailureTime?: number
|
||||
lastSuccessTime?: number
|
||||
lastStateChange?: number
|
||||
isOpen: boolean
|
||||
isClosed: boolean
|
||||
isHalfOpen: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when circuit is OPEN and request is rejected
|
||||
*/
|
||||
export class CircuitOpenError extends Error {
|
||||
constructor(
|
||||
public readonly circuitName: string,
|
||||
public readonly state: CircuitState
|
||||
) {
|
||||
super(`Circuit breaker "${circuitName}" is ${state}`)
|
||||
this.name = 'CircuitOpenError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when operation exceeds timeout
|
||||
*/
|
||||
export class CircuitTimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly circuitName: string,
|
||||
public readonly timeoutMs: number
|
||||
) {
|
||||
super(`Circuit breaker "${circuitName}" operation timed out after ${timeoutMs}ms`)
|
||||
this.name = 'CircuitTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker implementation with sliding window failure tracking
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const breaker = new CircuitBreaker({
|
||||
* name: 'whatsapp-api',
|
||||
* failureThreshold: 5,
|
||||
* resetTimeout: 30000
|
||||
* })
|
||||
*
|
||||
* try {
|
||||
* const result = await breaker.execute(() => sendMessage(msg))
|
||||
* } catch (error) {
|
||||
* if (error instanceof CircuitOpenError) {
|
||||
* // Circuit is open, use fallback
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CircuitBreaker extends EventEmitter {
|
||||
private state: CircuitState = 'closed'
|
||||
private failureRecords: FailureRecord[] = []
|
||||
private consecutiveFailures = 0
|
||||
private consecutiveSuccesses = 0
|
||||
private totalCalls = 0
|
||||
private totalFailures = 0
|
||||
private totalSuccesses = 0
|
||||
private totalRejected = 0
|
||||
private lastFailureTime?: number
|
||||
private lastSuccessTime?: number
|
||||
private lastStateChange: number
|
||||
private resetTimer?: ReturnType<typeof setTimeout>
|
||||
private readonly options: Required<CircuitBreakerOptions>
|
||||
|
||||
constructor(options: CircuitBreakerOptions) {
|
||||
super()
|
||||
|
||||
this.options = {
|
||||
name: options.name,
|
||||
failureThreshold: options.failureThreshold ?? 5,
|
||||
failureWindow: options.failureWindow ?? 60000,
|
||||
successThreshold: options.successThreshold ?? 2,
|
||||
resetTimeout: options.resetTimeout ?? 30000,
|
||||
timeout: options.timeout ?? 10000,
|
||||
volumeThreshold: options.volumeThreshold ?? 5,
|
||||
shouldCountError: options.shouldCountError ?? (() => true),
|
||||
collectMetrics: options.collectMetrics ?? true,
|
||||
fallback: options.fallback ?? (() => {
|
||||
throw new CircuitOpenError(this.options.name, this.state)
|
||||
}),
|
||||
onStateChange: options.onStateChange ?? (() => {}),
|
||||
onFailure: options.onFailure ?? (() => {}),
|
||||
onSuccess: options.onSuccess ?? (() => {}),
|
||||
onOpen: options.onOpen ?? (() => {}),
|
||||
onClose: options.onClose ?? (() => {}),
|
||||
onHalfOpen: options.onHalfOpen ?? (() => {}),
|
||||
}
|
||||
|
||||
this.lastStateChange = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the circuit allows execution
|
||||
*/
|
||||
canExecute(): boolean {
|
||||
if (this.state === 'closed') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (this.state === 'open') {
|
||||
return false
|
||||
}
|
||||
|
||||
// HALF_OPEN: allow limited requests for testing
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async operation with circuit breaker protection
|
||||
*/
|
||||
async execute<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
this.totalCalls++
|
||||
|
||||
// Check if circuit allows execution
|
||||
if (this.state === 'open') {
|
||||
this.totalRejected++
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.errors.inc({ category: 'circuit_breaker', code: 'rejected' })
|
||||
}
|
||||
|
||||
return this.options.fallback() as T
|
||||
}
|
||||
|
||||
// Execute with timeout protection
|
||||
try {
|
||||
const result = await this.executeWithTimeout(operation)
|
||||
this.recordSuccess()
|
||||
return result
|
||||
} catch (error) {
|
||||
this.recordFailure(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a synchronous operation with circuit breaker protection
|
||||
*/
|
||||
executeSync<T>(operation: () => T): T {
|
||||
this.totalCalls++
|
||||
|
||||
if (this.state === 'open') {
|
||||
this.totalRejected++
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.errors.inc({ category: 'circuit_breaker', code: 'rejected' })
|
||||
}
|
||||
|
||||
return this.options.fallback() as T
|
||||
}
|
||||
|
||||
try {
|
||||
const result = operation()
|
||||
this.recordSuccess()
|
||||
return result
|
||||
} catch (error) {
|
||||
this.recordFailure(error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with timeout wrapper
|
||||
*/
|
||||
private async executeWithTimeout<T>(operation: () => T | Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new CircuitTimeoutError(this.options.name, this.options.timeout))
|
||||
}, this.options.timeout)
|
||||
|
||||
Promise.resolve(operation())
|
||||
.then((result) => {
|
||||
clearTimeout(timer)
|
||||
resolve(result)
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful operation
|
||||
*/
|
||||
private recordSuccess(): void {
|
||||
this.totalSuccesses++
|
||||
this.lastSuccessTime = Date.now()
|
||||
this.consecutiveSuccesses++
|
||||
this.consecutiveFailures = 0
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.socketEvents.inc({ event: 'circuit_success' })
|
||||
}
|
||||
|
||||
this.options.onSuccess()
|
||||
this.emit('success')
|
||||
|
||||
// In HALF_OPEN state, check if we can close the circuit
|
||||
if (this.state === 'half-open') {
|
||||
if (this.consecutiveSuccesses >= this.options.successThreshold) {
|
||||
this.transitionTo('closed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed operation
|
||||
*/
|
||||
private recordFailure(error: Error): void {
|
||||
// Check if this error should count as a failure
|
||||
if (!this.options.shouldCountError(error)) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
this.totalFailures++
|
||||
this.lastFailureTime = now
|
||||
this.consecutiveFailures++
|
||||
this.consecutiveSuccesses = 0
|
||||
|
||||
// Add to failure records for sliding window
|
||||
this.failureRecords.push({ timestamp: now, error })
|
||||
|
||||
// Clean old failures outside the window
|
||||
this.cleanOldFailures()
|
||||
|
||||
if (this.options.collectMetrics) {
|
||||
metrics.errors.inc({ category: 'circuit_breaker', code: 'failure' })
|
||||
}
|
||||
|
||||
this.options.onFailure(error)
|
||||
this.emit('failure', error)
|
||||
|
||||
// State transition logic
|
||||
if (this.state === 'half-open') {
|
||||
// Any failure in HALF_OPEN immediately reopens the circuit
|
||||
this.transitionTo('open')
|
||||
} else if (this.state === 'closed') {
|
||||
// Check if we should trip the circuit
|
||||
const recentFailures = this.failureRecords.length
|
||||
|
||||
if (
|
||||
this.totalCalls >= this.options.volumeThreshold &&
|
||||
recentFailures >= this.options.failureThreshold
|
||||
) {
|
||||
this.transitionTo('open')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove failure records outside the sliding window
|
||||
*/
|
||||
private cleanOldFailures(): void {
|
||||
const cutoff = Date.now() - this.options.failureWindow
|
||||
this.failureRecords = this.failureRecords.filter(
|
||||
(record) => record.timestamp > cutoff
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to a new state
|
||||
*/
|
||||
private transitionTo(newState: CircuitState): void {
|
||||
const oldState = this.state
|
||||
|
||||
if (oldState === newState) {
|
||||
return
|
||||
}
|
||||
|
||||
this.state = newState
|
||||
this.lastStateChange = Date.now()
|
||||
|
||||
// Clear existing reset timer
|
||||
if (this.resetTimer) {
|
||||
clearTimeout(this.resetTimer)
|
||||
this.resetTimer = undefined
|
||||
}
|
||||
|
||||
// State-specific actions
|
||||
switch (newState) {
|
||||
case 'closed':
|
||||
this.consecutiveFailures = 0
|
||||
this.consecutiveSuccesses = 0
|
||||
this.failureRecords = []
|
||||
this.options.onClose()
|
||||
this.emit('close')
|
||||
break
|
||||
|
||||
case 'open':
|
||||
this.consecutiveSuccesses = 0
|
||||
this.options.onOpen()
|
||||
this.emit('open')
|
||||
|
||||
// Schedule transition to HALF_OPEN
|
||||
this.resetTimer = setTimeout(() => {
|
||||
this.transitionTo('half-open')
|
||||
}, this.options.resetTimeout)
|
||||
break
|
||||
|
||||
case 'half-open':
|
||||
this.consecutiveSuccesses = 0
|
||||
this.consecutiveFailures = 0
|
||||
this.options.onHalfOpen()
|
||||
this.emit('half-open')
|
||||
break
|
||||
}
|
||||
|
||||
this.options.onStateChange(oldState, newState)
|
||||
this.emit('state-change', { from: oldState, to: newState })
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trip the circuit to OPEN state
|
||||
*/
|
||||
trip(): void {
|
||||
this.transitionTo('open')
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually reset the circuit to CLOSED state
|
||||
*/
|
||||
reset(): void {
|
||||
this.transitionTo('closed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current circuit state
|
||||
*/
|
||||
getState(): CircuitState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is OPEN
|
||||
*/
|
||||
isOpen(): boolean {
|
||||
return this.state === 'open'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is CLOSED
|
||||
*/
|
||||
isClosed(): boolean {
|
||||
return this.state === 'closed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if circuit is HALF_OPEN
|
||||
*/
|
||||
isHalfOpen(): boolean {
|
||||
return this.state === 'half-open'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker statistics
|
||||
*/
|
||||
getStats(): CircuitBreakerStats {
|
||||
this.cleanOldFailures()
|
||||
|
||||
const failureRate = this.totalCalls > 0
|
||||
? (this.totalFailures / this.totalCalls) * 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
state: this.state,
|
||||
failures: this.failureRecords.length,
|
||||
successes: this.consecutiveSuccesses,
|
||||
consecutiveFailures: this.consecutiveFailures,
|
||||
consecutiveSuccesses: this.consecutiveSuccesses,
|
||||
totalCalls: this.totalCalls,
|
||||
totalFailures: this.totalFailures,
|
||||
totalSuccesses: this.totalSuccesses,
|
||||
totalRejected: this.totalRejected,
|
||||
failureRate,
|
||||
lastFailureTime: this.lastFailureTime,
|
||||
lastSuccessTime: this.lastSuccessTime,
|
||||
lastStateChange: this.lastStateChange,
|
||||
isOpen: this.isOpen(),
|
||||
isClosed: this.isClosed(),
|
||||
isHalfOpen: this.isHalfOpen(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get circuit breaker name
|
||||
*/
|
||||
getName(): string {
|
||||
return this.options.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy circuit breaker and clean up resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.resetTimer) {
|
||||
clearTimeout(this.resetTimer)
|
||||
}
|
||||
this.failureRecords = []
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a circuit breaker
|
||||
*/
|
||||
export function createCircuitBreaker(options: CircuitBreakerOptions): CircuitBreaker {
|
||||
return new CircuitBreaker(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry for managing multiple circuit breakers
|
||||
*/
|
||||
export class CircuitBreakerRegistry {
|
||||
private breakers: Map<string, CircuitBreaker> = new Map()
|
||||
|
||||
/**
|
||||
* Get or create a circuit breaker by name
|
||||
*/
|
||||
get(name: string, options?: Omit<CircuitBreakerOptions, 'name'>): CircuitBreaker {
|
||||
if (!this.breakers.has(name)) {
|
||||
const breaker = new CircuitBreaker({ ...options, name })
|
||||
this.breakers.set(name, breaker)
|
||||
}
|
||||
return this.breakers.get(name)!
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a circuit breaker exists
|
||||
*/
|
||||
has(name: string): boolean {
|
||||
return this.breakers.has(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a circuit breaker
|
||||
*/
|
||||
remove(name: string): boolean {
|
||||
const breaker = this.breakers.get(name)
|
||||
if (breaker) {
|
||||
breaker.destroy()
|
||||
return this.breakers.delete(name)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all circuit breakers
|
||||
*/
|
||||
getAll(): Map<string, CircuitBreaker> {
|
||||
return new Map(this.breakers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics for all circuit breakers
|
||||
*/
|
||||
getAllStats(): Record<string, CircuitBreakerStats> {
|
||||
const stats: Record<string, CircuitBreakerStats> = {}
|
||||
for (const [name, breaker] of this.breakers) {
|
||||
stats[name] = breaker.getStats()
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all circuit breakers to CLOSED state
|
||||
*/
|
||||
resetAll(): void {
|
||||
for (const breaker of this.breakers.values()) {
|
||||
breaker.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all circuit breakers
|
||||
*/
|
||||
destroyAll(): void {
|
||||
for (const breaker of this.breakers.values()) {
|
||||
breaker.destroy()
|
||||
}
|
||||
this.breakers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global circuit breaker registry instance
|
||||
*/
|
||||
export const globalCircuitRegistry = new CircuitBreakerRegistry()
|
||||
|
||||
/**
|
||||
* Decorator to protect a method with circuit breaker
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyService {
|
||||
* @circuitBreaker({ failureThreshold: 3 })
|
||||
* async fetchData() {
|
||||
* return await api.getData()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function circuitBreaker(options: Omit<CircuitBreakerOptions, 'name'> & { name?: string } = {}) {
|
||||
return function (
|
||||
_target: unknown,
|
||||
propertyKey: string,
|
||||
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => unknown>
|
||||
) {
|
||||
const originalMethod = descriptor.value
|
||||
if (!originalMethod) return descriptor
|
||||
|
||||
const name = options.name || propertyKey
|
||||
const breaker = globalCircuitRegistry.get(name, options)
|
||||
|
||||
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
|
||||
return breaker.execute(() => originalMethod.apply(this, args))
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a function with circuit breaker protection
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const protectedFetch = withCircuitBreaker(
|
||||
* fetchData,
|
||||
* { name: 'api-fetch', failureThreshold: 5 }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function withCircuitBreaker<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
options: CircuitBreakerOptions
|
||||
): T {
|
||||
const breaker = new CircuitBreaker(options)
|
||||
|
||||
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||
return breaker.execute(() => fn(...args)) as Promise<ReturnType<T>>
|
||||
}) as unknown as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health status of all circuit breakers
|
||||
*/
|
||||
export function getCircuitHealth(): {
|
||||
healthy: boolean
|
||||
openCircuits: string[]
|
||||
stats: Record<string, CircuitBreakerStats>
|
||||
} {
|
||||
const stats = globalCircuitRegistry.getAllStats()
|
||||
const openCircuits: string[] = []
|
||||
|
||||
for (const [name, stat] of Object.entries(stats)) {
|
||||
if (stat.isOpen) {
|
||||
openCircuits.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
healthy: openCircuits.length === 0,
|
||||
openCircuits,
|
||||
stats,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pre-configured circuit breaker for WhatsApp PreKey operations
|
||||
*
|
||||
* This circuit breaker is optimized for handling encryption/session errors
|
||||
* that commonly occur with WhatsApp's Signal protocol implementation.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const preKeyBreaker = createPreKeyCircuitBreaker()
|
||||
*
|
||||
* async function sendEncryptedMessage(msg) {
|
||||
* return preKeyBreaker.execute(async () => {
|
||||
* return await encryptAndSend(msg)
|
||||
* })
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createPreKeyCircuitBreaker(
|
||||
customOptions?: Partial<CircuitBreakerOptions>
|
||||
): CircuitBreaker {
|
||||
const preKeyErrorPatterns = [
|
||||
'prekey',
|
||||
'pre-key',
|
||||
'session',
|
||||
'signal',
|
||||
'encrypt',
|
||||
'decrypt',
|
||||
'cipher',
|
||||
'key',
|
||||
]
|
||||
|
||||
return new CircuitBreaker({
|
||||
name: 'prekey-operations',
|
||||
failureThreshold: 5,
|
||||
failureWindow: 60000,
|
||||
resetTimeout: 30000,
|
||||
successThreshold: 2,
|
||||
shouldCountError: (error: Error) => {
|
||||
const message = error.message.toLowerCase()
|
||||
return preKeyErrorPatterns.some((pattern) => message.includes(pattern))
|
||||
},
|
||||
...customOptions,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a circuit breaker for WebSocket connection operations
|
||||
*/
|
||||
export function createConnectionCircuitBreaker(
|
||||
customOptions?: Partial<CircuitBreakerOptions>
|
||||
): CircuitBreaker {
|
||||
const connectionErrorPatterns = [
|
||||
'econnrefused',
|
||||
'econnreset',
|
||||
'etimedout',
|
||||
'enotfound',
|
||||
'socket',
|
||||
'websocket',
|
||||
'connection',
|
||||
'network',
|
||||
]
|
||||
|
||||
return new CircuitBreaker({
|
||||
name: 'connection-operations',
|
||||
failureThreshold: 3,
|
||||
failureWindow: 30000,
|
||||
resetTimeout: 60000,
|
||||
successThreshold: 1,
|
||||
shouldCountError: (error: Error) => {
|
||||
const message = error.message.toLowerCase()
|
||||
const code = (error as NodeJS.ErrnoException).code?.toLowerCase() || ''
|
||||
return connectionErrorPatterns.some(
|
||||
(pattern) => message.includes(pattern) || code.includes(pattern)
|
||||
)
|
||||
},
|
||||
...customOptions,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a circuit breaker for message sending operations
|
||||
*/
|
||||
export function createMessageCircuitBreaker(
|
||||
customOptions?: Partial<CircuitBreakerOptions>
|
||||
): CircuitBreaker {
|
||||
return new CircuitBreaker({
|
||||
name: 'message-operations',
|
||||
failureThreshold: 5,
|
||||
failureWindow: 60000,
|
||||
resetTimeout: 15000,
|
||||
successThreshold: 2,
|
||||
timeout: 30000,
|
||||
...customOptions,
|
||||
})
|
||||
}
|
||||
|
||||
export default CircuitBreaker
|
||||
@@ -16,3 +16,22 @@ export * from './event-buffer'
|
||||
export * from './process-message'
|
||||
export * from './message-retry-manager'
|
||||
export * from './browser-utils'
|
||||
|
||||
// === Observability and Resilience Utilities ===
|
||||
|
||||
// Structured logging
|
||||
export * from './structured-logger'
|
||||
export * from './logger-adapter'
|
||||
export * from './baileys-logger'
|
||||
|
||||
// Observability and tracing
|
||||
export * from './trace-context'
|
||||
export * from './prometheus-metrics'
|
||||
|
||||
// Resilience and performance
|
||||
export * from './cache-utils'
|
||||
export * from './circuit-breaker'
|
||||
export * from './retry-utils'
|
||||
|
||||
// Event streaming
|
||||
export * from './baileys-event-stream'
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* @fileoverview Adaptador entre diferentes sistemas de logging
|
||||
* @module Utils/logger-adapter
|
||||
*
|
||||
* Fornece:
|
||||
* - Adapter pattern para integrar diferentes loggers
|
||||
* - Mapeamento de níveis de log entre sistemas
|
||||
* - Transformação de formatos de log
|
||||
* - Compatibilidade com Pino, Console e StructuredLogger
|
||||
*/
|
||||
|
||||
import type { ILogger } from './logger.js'
|
||||
import type P from 'pino'
|
||||
import { StructuredLogger, createStructuredLogger, type LogLevel, LOG_LEVEL_VALUES } from './structured-logger.js'
|
||||
|
||||
/**
|
||||
* Tipo de logger suportado
|
||||
*/
|
||||
export type LoggerType = 'pino' | 'console' | 'structured' | 'custom'
|
||||
|
||||
/**
|
||||
* Configuração do adapter
|
||||
*/
|
||||
export interface LoggerAdapterConfig {
|
||||
/** Tipo de logger de origem */
|
||||
sourceType: LoggerType
|
||||
/** Tipo de logger de destino */
|
||||
targetType: LoggerType
|
||||
/** Mapeamento customizado de níveis */
|
||||
levelMapping?: Record<string, LogLevel>
|
||||
/** Transformador de contexto */
|
||||
contextTransformer?: (context: Record<string, unknown>) => Record<string, unknown>
|
||||
/** Filtro de logs */
|
||||
logFilter?: (level: LogLevel, message: string, data?: unknown) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapeamento padrão de níveis Pino para StructuredLogger
|
||||
*/
|
||||
const PINO_LEVEL_MAPPING: Record<number, LogLevel> = {
|
||||
10: 'trace',
|
||||
20: 'debug',
|
||||
30: 'info',
|
||||
40: 'warn',
|
||||
50: 'error',
|
||||
60: 'fatal',
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapeamento reverso para Pino
|
||||
*/
|
||||
const STRUCTURED_TO_PINO_LEVEL: Record<LogLevel, number> = {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
info: 30,
|
||||
warn: 40,
|
||||
error: 50,
|
||||
fatal: 60,
|
||||
silent: 100,
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe adaptadora principal
|
||||
*/
|
||||
export class LoggerAdapter implements ILogger {
|
||||
private sourceLogger: ILogger
|
||||
private targetLogger: ILogger | null = null
|
||||
private config: LoggerAdapterConfig
|
||||
|
||||
constructor(sourceLogger: ILogger, config: Partial<LoggerAdapterConfig> = {}) {
|
||||
this.sourceLogger = sourceLogger
|
||||
this.config = {
|
||||
sourceType: config.sourceType || 'pino',
|
||||
targetType: config.targetType || 'structured',
|
||||
levelMapping: config.levelMapping,
|
||||
contextTransformer: config.contextTransformer,
|
||||
logFilter: config.logFilter,
|
||||
}
|
||||
}
|
||||
|
||||
get level(): string {
|
||||
return this.sourceLogger.level
|
||||
}
|
||||
|
||||
set level(newLevel: string) {
|
||||
this.sourceLogger.level = newLevel
|
||||
if (this.targetLogger) {
|
||||
this.targetLogger.level = newLevel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define o logger de destino
|
||||
*/
|
||||
setTargetLogger(logger: ILogger): void {
|
||||
this.targetLogger = logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um logger filho
|
||||
*/
|
||||
child(obj: Record<string, unknown>): LoggerAdapter {
|
||||
const transformedContext = this.config.contextTransformer ? this.config.contextTransformer(obj) : obj
|
||||
|
||||
const childAdapter = new LoggerAdapter(this.sourceLogger.child(transformedContext), this.config)
|
||||
|
||||
if (this.targetLogger) {
|
||||
childAdapter.setTargetLogger(this.targetLogger.child(transformedContext))
|
||||
}
|
||||
|
||||
return childAdapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapeia nível de log
|
||||
*/
|
||||
private mapLevel(level: string | number): LogLevel {
|
||||
if (typeof level === 'number') {
|
||||
return PINO_LEVEL_MAPPING[level] || 'info'
|
||||
}
|
||||
|
||||
if (this.config.levelMapping && level in this.config.levelMapping) {
|
||||
return this.config.levelMapping[level]
|
||||
}
|
||||
|
||||
return (level as LogLevel) || 'info'
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se o log deve ser processado
|
||||
*/
|
||||
private shouldLog(level: LogLevel, msg: string, obj?: unknown): boolean {
|
||||
if (this.config.logFilter) {
|
||||
return this.config.logFilter(level, msg, obj)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Processa log em ambos loggers
|
||||
*/
|
||||
private processLog(level: LogLevel, obj: unknown, msg?: string): void {
|
||||
if (!this.shouldLog(level, msg || '', obj)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Log no source logger
|
||||
const sourceMethod = this.sourceLogger[level as keyof ILogger]
|
||||
if (typeof sourceMethod === 'function') {
|
||||
;(sourceMethod as (obj: unknown, msg?: string) => void).call(this.sourceLogger, obj, msg)
|
||||
}
|
||||
|
||||
// Log no target logger se configurado
|
||||
if (this.targetLogger) {
|
||||
const targetMethod = this.targetLogger[level as keyof ILogger]
|
||||
if (typeof targetMethod === 'function') {
|
||||
;(targetMethod as (obj: unknown, msg?: string) => void).call(this.targetLogger, obj, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trace(obj: unknown, msg?: string): void {
|
||||
this.processLog('trace', obj, msg)
|
||||
}
|
||||
|
||||
debug(obj: unknown, msg?: string): void {
|
||||
this.processLog('debug', obj, msg)
|
||||
}
|
||||
|
||||
info(obj: unknown, msg?: string): void {
|
||||
this.processLog('info', obj, msg)
|
||||
}
|
||||
|
||||
warn(obj: unknown, msg?: string): void {
|
||||
this.processLog('warn', obj, msg)
|
||||
}
|
||||
|
||||
error(obj: unknown, msg?: string): void {
|
||||
this.processLog('error', obj, msg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper para converter Pino logger em StructuredLogger
|
||||
*/
|
||||
export class PinoToStructuredAdapter implements ILogger {
|
||||
private pinoLogger: P.Logger
|
||||
private structuredLogger: StructuredLogger
|
||||
|
||||
constructor(pinoLogger: P.Logger, structuredLoggerConfig?: Parameters<typeof createStructuredLogger>[0]) {
|
||||
this.pinoLogger = pinoLogger
|
||||
this.structuredLogger = createStructuredLogger({
|
||||
level: this.mapPinoLevel(pinoLogger.level),
|
||||
...structuredLoggerConfig,
|
||||
})
|
||||
}
|
||||
|
||||
get level(): string {
|
||||
return this.pinoLogger.level
|
||||
}
|
||||
|
||||
set level(newLevel: string) {
|
||||
this.pinoLogger.level = newLevel
|
||||
this.structuredLogger.level = newLevel
|
||||
}
|
||||
|
||||
private mapPinoLevel(pinoLevel: string): LogLevel {
|
||||
const levelMap: Record<string, LogLevel> = {
|
||||
trace: 'trace',
|
||||
debug: 'debug',
|
||||
info: 'info',
|
||||
warn: 'warn',
|
||||
error: 'error',
|
||||
fatal: 'fatal',
|
||||
silent: 'silent',
|
||||
}
|
||||
return levelMap[pinoLevel] || 'info'
|
||||
}
|
||||
|
||||
child(obj: Record<string, unknown>): PinoToStructuredAdapter {
|
||||
const adapter = new PinoToStructuredAdapter(this.pinoLogger.child(obj))
|
||||
return adapter
|
||||
}
|
||||
|
||||
trace(obj: unknown, msg?: string): void {
|
||||
this.pinoLogger.trace(obj as object, msg)
|
||||
this.structuredLogger.trace(obj, msg)
|
||||
}
|
||||
|
||||
debug(obj: unknown, msg?: string): void {
|
||||
this.pinoLogger.debug(obj as object, msg)
|
||||
this.structuredLogger.debug(obj, msg)
|
||||
}
|
||||
|
||||
info(obj: unknown, msg?: string): void {
|
||||
this.pinoLogger.info(obj as object, msg)
|
||||
this.structuredLogger.info(obj, msg)
|
||||
}
|
||||
|
||||
warn(obj: unknown, msg?: string): void {
|
||||
this.pinoLogger.warn(obj as object, msg)
|
||||
this.structuredLogger.warn(obj, msg)
|
||||
}
|
||||
|
||||
error(obj: unknown, msg?: string): void {
|
||||
this.pinoLogger.error(obj as object, msg)
|
||||
this.structuredLogger.error(obj, msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém métricas do structured logger
|
||||
*/
|
||||
getMetrics() {
|
||||
return this.structuredLogger.getMetrics()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory para criar adapter baseado no tipo de logger
|
||||
*/
|
||||
export function createLoggerAdapter(
|
||||
logger: ILogger,
|
||||
config?: Partial<LoggerAdapterConfig>
|
||||
): LoggerAdapter {
|
||||
return new LoggerAdapter(logger, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converte qualquer logger para a interface ILogger
|
||||
*/
|
||||
export function normalizeLogger(logger: unknown): ILogger {
|
||||
if (isILogger(logger)) {
|
||||
return logger
|
||||
}
|
||||
|
||||
// Se for um objeto com métodos de log
|
||||
if (typeof logger === 'object' && logger !== null) {
|
||||
const logObj = logger as Record<string, unknown>
|
||||
|
||||
return {
|
||||
level: (logObj.level as string) || 'info',
|
||||
child: (obj: Record<string, unknown>) => {
|
||||
if (typeof logObj.child === 'function') {
|
||||
return normalizeLogger((logObj.child as (obj: Record<string, unknown>) => unknown)(obj))
|
||||
}
|
||||
return normalizeLogger(logger)
|
||||
},
|
||||
trace: createLogMethod(logObj, 'trace'),
|
||||
debug: createLogMethod(logObj, 'debug'),
|
||||
info: createLogMethod(logObj, 'info'),
|
||||
warn: createLogMethod(logObj, 'warn'),
|
||||
error: createLogMethod(logObj, 'error'),
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: console logger
|
||||
return createConsoleLogger()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se objeto implementa ILogger
|
||||
*/
|
||||
export function isILogger(obj: unknown): obj is ILogger {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const logger = obj as Record<string, unknown>
|
||||
|
||||
return (
|
||||
typeof logger.child === 'function' &&
|
||||
typeof logger.trace === 'function' &&
|
||||
typeof logger.debug === 'function' &&
|
||||
typeof logger.info === 'function' &&
|
||||
typeof logger.warn === 'function' &&
|
||||
typeof logger.error === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria método de log genérico
|
||||
*/
|
||||
function createLogMethod(
|
||||
logger: Record<string, unknown>,
|
||||
level: string
|
||||
): (obj: unknown, msg?: string) => void {
|
||||
return (obj: unknown, msg?: string) => {
|
||||
if (typeof logger[level] === 'function') {
|
||||
;(logger[level] as (obj: unknown, msg?: string) => void)(obj, msg)
|
||||
} else if (typeof (console as Record<string, unknown>)[level] === 'function') {
|
||||
;(console as Record<string, (...args: unknown[]) => void>)[level](obj, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um logger baseado em console
|
||||
*/
|
||||
export function createConsoleLogger(prefix?: string): ILogger {
|
||||
const formatMessage = (level: string, obj: unknown, msg?: string): string => {
|
||||
const timestamp = new Date().toISOString()
|
||||
const prefixStr = prefix ? `[${prefix}]` : ''
|
||||
const message = msg || (typeof obj === 'string' ? obj : '')
|
||||
const data = typeof obj === 'object' ? JSON.stringify(obj) : ''
|
||||
|
||||
return `${timestamp} ${prefixStr}[${level.toUpperCase()}] ${message} ${data}`.trim()
|
||||
}
|
||||
|
||||
return {
|
||||
level: 'info',
|
||||
child(obj: Record<string, unknown>): ILogger {
|
||||
const childPrefix = prefix ? `${prefix}:${Object.values(obj)[0]}` : String(Object.values(obj)[0])
|
||||
return createConsoleLogger(childPrefix)
|
||||
},
|
||||
trace(obj: unknown, msg?: string): void {
|
||||
console.debug(formatMessage('trace', obj, msg))
|
||||
},
|
||||
debug(obj: unknown, msg?: string): void {
|
||||
console.debug(formatMessage('debug', obj, msg))
|
||||
},
|
||||
info(obj: unknown, msg?: string): void {
|
||||
console.info(formatMessage('info', obj, msg))
|
||||
},
|
||||
warn(obj: unknown, msg?: string): void {
|
||||
console.warn(formatMessage('warn', obj, msg))
|
||||
},
|
||||
error(obj: unknown, msg?: string): void {
|
||||
console.error(formatMessage('error', obj, msg))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default LoggerAdapter
|
||||
@@ -0,0 +1,785 @@
|
||||
/**
|
||||
* Prometheus Metrics Exposition
|
||||
*
|
||||
* Provides:
|
||||
* - Counters for event counting
|
||||
* - Gauges for instantaneous values
|
||||
* - Histograms for value distribution
|
||||
* - Summaries for percentiles
|
||||
* - /metrics endpoint ready for scraping
|
||||
* - Dynamic labels
|
||||
* - Baileys events integration
|
||||
*
|
||||
* Note: Works standalone without prom-client,
|
||||
* but can be integrated with prom-client if available.
|
||||
*
|
||||
* @module Utils/prometheus-metrics
|
||||
*/
|
||||
|
||||
/**
|
||||
* Metric type
|
||||
*/
|
||||
export type MetricType = 'counter' | 'gauge' | 'histogram' | 'summary'
|
||||
|
||||
/**
|
||||
* Metric labels
|
||||
*/
|
||||
export type Labels = Record<string, string>
|
||||
|
||||
/**
|
||||
* Default histogram buckets (in ms)
|
||||
*/
|
||||
export const DEFAULT_BUCKETS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
|
||||
|
||||
/**
|
||||
* Default summary percentiles
|
||||
*/
|
||||
export const DEFAULT_PERCENTILES = [0.5, 0.9, 0.95, 0.99]
|
||||
|
||||
/**
|
||||
* Base metric interface
|
||||
*/
|
||||
export interface BaseMetric {
|
||||
name: string
|
||||
help: string
|
||||
type: MetricType
|
||||
labelNames: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric value with labels
|
||||
*/
|
||||
export interface MetricValue {
|
||||
labels: Labels
|
||||
value: number
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Histogram values
|
||||
*/
|
||||
export interface HistogramValue {
|
||||
labels: Labels
|
||||
buckets: Map<number, number>
|
||||
sum: number
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary values
|
||||
*/
|
||||
export interface SummaryValue {
|
||||
labels: Labels
|
||||
values: number[]
|
||||
sum: number
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter class - monotonically increasing
|
||||
*/
|
||||
export class Counter implements BaseMetric {
|
||||
readonly type = 'counter' as const
|
||||
private values: Map<string, MetricValue> = new Map()
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public help: string,
|
||||
public labelNames: string[] = []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Increment the counter
|
||||
*/
|
||||
inc(labelsOrValue?: Labels | number, value?: number): void {
|
||||
let labels: Labels = {}
|
||||
let incValue = 1
|
||||
|
||||
if (typeof labelsOrValue === 'number') {
|
||||
incValue = labelsOrValue
|
||||
} else if (labelsOrValue) {
|
||||
labels = labelsOrValue
|
||||
incValue = value ?? 1
|
||||
}
|
||||
|
||||
const key = this.labelsToKey(labels)
|
||||
const existing = this.values.get(key)
|
||||
|
||||
if (existing) {
|
||||
existing.value += incValue
|
||||
existing.timestamp = Date.now()
|
||||
} else {
|
||||
this.values.set(key, {
|
||||
labels,
|
||||
value: incValue,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current value
|
||||
*/
|
||||
get(labels: Labels = {}): number {
|
||||
const key = this.labelsToKey(labels)
|
||||
return this.values.get(key)?.value ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the counter
|
||||
*/
|
||||
reset(labels?: Labels): void {
|
||||
if (labels) {
|
||||
const key = this.labelsToKey(labels)
|
||||
this.values.delete(key)
|
||||
} else {
|
||||
this.values.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values
|
||||
*/
|
||||
getValues(): MetricValue[] {
|
||||
return Array.from(this.values.values())
|
||||
}
|
||||
|
||||
private labelsToKey(labels: Labels): string {
|
||||
return JSON.stringify(labels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version with pre-defined labels
|
||||
*/
|
||||
labels(labels: Labels): { inc: (value?: number) => void } {
|
||||
return {
|
||||
inc: (value?: number) => this.inc(labels, value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gauge class - value that can increase and decrease
|
||||
*/
|
||||
export class Gauge implements BaseMetric {
|
||||
readonly type = 'gauge' as const
|
||||
private values: Map<string, MetricValue> = new Map()
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public help: string,
|
||||
public labelNames: string[] = []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set value
|
||||
*/
|
||||
set(labelsOrValue: Labels | number, value?: number): void {
|
||||
let labels: Labels = {}
|
||||
let setValue: number
|
||||
|
||||
if (typeof labelsOrValue === 'number') {
|
||||
setValue = labelsOrValue
|
||||
} else {
|
||||
labels = labelsOrValue
|
||||
setValue = value ?? 0
|
||||
}
|
||||
|
||||
const key = this.labelsToKey(labels)
|
||||
this.values.set(key, {
|
||||
labels,
|
||||
value: setValue,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment value
|
||||
*/
|
||||
inc(labelsOrValue?: Labels | number, value?: number): void {
|
||||
let labels: Labels = {}
|
||||
let incValue = 1
|
||||
|
||||
if (typeof labelsOrValue === 'number') {
|
||||
incValue = labelsOrValue
|
||||
} else if (labelsOrValue) {
|
||||
labels = labelsOrValue
|
||||
incValue = value ?? 1
|
||||
}
|
||||
|
||||
const key = this.labelsToKey(labels)
|
||||
const existing = this.values.get(key)
|
||||
const currentValue = existing?.value ?? 0
|
||||
|
||||
this.set(labels, currentValue + incValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement value
|
||||
*/
|
||||
dec(labelsOrValue?: Labels | number, value?: number): void {
|
||||
let labels: Labels = {}
|
||||
let decValue = 1
|
||||
|
||||
if (typeof labelsOrValue === 'number') {
|
||||
decValue = labelsOrValue
|
||||
} else if (labelsOrValue) {
|
||||
labels = labelsOrValue
|
||||
decValue = value ?? 1
|
||||
}
|
||||
|
||||
const key = this.labelsToKey(labels)
|
||||
const existing = this.values.get(key)
|
||||
const currentValue = existing?.value ?? 0
|
||||
|
||||
this.set(labels, currentValue - decValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set to current timestamp
|
||||
*/
|
||||
setToCurrentTime(labels: Labels = {}): void {
|
||||
this.set(labels, Date.now() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current value
|
||||
*/
|
||||
get(labels: Labels = {}): number {
|
||||
const key = this.labelsToKey(labels)
|
||||
return this.values.get(key)?.value ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the gauge
|
||||
*/
|
||||
reset(labels?: Labels): void {
|
||||
if (labels) {
|
||||
const key = this.labelsToKey(labels)
|
||||
this.values.delete(key)
|
||||
} else {
|
||||
this.values.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values
|
||||
*/
|
||||
getValues(): MetricValue[] {
|
||||
return Array.from(this.values.values())
|
||||
}
|
||||
|
||||
private labelsToKey(labels: Labels): string {
|
||||
return JSON.stringify(labels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version with pre-defined labels
|
||||
*/
|
||||
labels(labels: Labels): {
|
||||
set: (value: number) => void
|
||||
inc: (value?: number) => void
|
||||
dec: (value?: number) => void
|
||||
} {
|
||||
return {
|
||||
set: (value: number) => this.set(labels, value),
|
||||
inc: (value?: number) => this.inc(labels, value),
|
||||
dec: (value?: number) => this.dec(labels, value),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer helper - returns function to stop and record duration
|
||||
*/
|
||||
startTimer(labels: Labels = {}): () => number {
|
||||
const start = process.hrtime.bigint()
|
||||
return () => {
|
||||
const duration = Number(process.hrtime.bigint() - start) / 1_000_000_000 // seconds
|
||||
this.set(labels, duration)
|
||||
return duration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Histogram class - distribution of values in buckets
|
||||
*/
|
||||
export class Histogram implements BaseMetric {
|
||||
readonly type = 'histogram' as const
|
||||
private values: Map<string, HistogramValue> = new Map()
|
||||
private buckets: number[]
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public help: string,
|
||||
public labelNames: string[] = [],
|
||||
buckets: number[] = DEFAULT_BUCKETS
|
||||
) {
|
||||
this.buckets = [...buckets].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe a value
|
||||
*/
|
||||
observe(labelsOrValue: Labels | number, value?: number): void {
|
||||
let labels: Labels = {}
|
||||
let observeValue: number
|
||||
|
||||
if (typeof labelsOrValue === 'number') {
|
||||
observeValue = labelsOrValue
|
||||
} else {
|
||||
labels = labelsOrValue
|
||||
observeValue = value ?? 0
|
||||
}
|
||||
|
||||
const key = this.labelsToKey(labels)
|
||||
let histValue = this.values.get(key)
|
||||
|
||||
if (!histValue) {
|
||||
histValue = {
|
||||
labels,
|
||||
buckets: new Map(this.buckets.map((b) => [b, 0])),
|
||||
sum: 0,
|
||||
count: 0,
|
||||
}
|
||||
this.values.set(key, histValue)
|
||||
}
|
||||
|
||||
// Increment appropriate buckets
|
||||
for (const bucket of this.buckets) {
|
||||
if (observeValue <= bucket) {
|
||||
histValue.buckets.set(bucket, (histValue.buckets.get(bucket) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
histValue.sum += observeValue
|
||||
histValue.count++
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer helper
|
||||
*/
|
||||
startTimer(labels: Labels = {}): () => number {
|
||||
const start = process.hrtime.bigint()
|
||||
return () => {
|
||||
const duration = Number(process.hrtime.bigint() - start) / 1_000_000 // ms
|
||||
this.observe(labels, duration)
|
||||
return duration
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get histogram values
|
||||
*/
|
||||
get(labels: Labels = {}): HistogramValue | undefined {
|
||||
const key = this.labelsToKey(labels)
|
||||
return this.values.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the histogram
|
||||
*/
|
||||
reset(labels?: Labels): void {
|
||||
if (labels) {
|
||||
const key = this.labelsToKey(labels)
|
||||
this.values.delete(key)
|
||||
} else {
|
||||
this.values.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values
|
||||
*/
|
||||
getValues(): HistogramValue[] {
|
||||
return Array.from(this.values.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured buckets
|
||||
*/
|
||||
getBuckets(): number[] {
|
||||
return [...this.buckets]
|
||||
}
|
||||
|
||||
private labelsToKey(labels: Labels): string {
|
||||
return JSON.stringify(labels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version with pre-defined labels
|
||||
*/
|
||||
labels(labels: Labels): {
|
||||
observe: (value: number) => void
|
||||
startTimer: () => () => number
|
||||
} {
|
||||
return {
|
||||
observe: (value: number) => this.observe(labels, value),
|
||||
startTimer: () => this.startTimer(labels),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary class - value percentiles
|
||||
*/
|
||||
export class Summary implements BaseMetric {
|
||||
readonly type = 'summary' as const
|
||||
private values: Map<string, SummaryValue> = new Map()
|
||||
private percentiles: number[]
|
||||
private maxAge: number // ms
|
||||
private maxSize: number
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
public help: string,
|
||||
public labelNames: string[] = [],
|
||||
options: { percentiles?: number[]; maxAge?: number; maxSize?: number } = {}
|
||||
) {
|
||||
this.percentiles = options.percentiles ?? DEFAULT_PERCENTILES
|
||||
this.maxAge = options.maxAge ?? 600000 // 10 min
|
||||
this.maxSize = options.maxSize ?? 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe a value
|
||||
*/
|
||||
observe(labelsOrValue: Labels | number, value?: number): void {
|
||||
let labels: Labels = {}
|
||||
let observeValue: number
|
||||
|
||||
if (typeof labelsOrValue === 'number') {
|
||||
observeValue = labelsOrValue
|
||||
} else {
|
||||
labels = labelsOrValue
|
||||
observeValue = value ?? 0
|
||||
}
|
||||
|
||||
const key = this.labelsToKey(labels)
|
||||
let summaryValue = this.values.get(key)
|
||||
|
||||
if (!summaryValue) {
|
||||
summaryValue = {
|
||||
labels,
|
||||
values: [],
|
||||
sum: 0,
|
||||
count: 0,
|
||||
}
|
||||
this.values.set(key, summaryValue)
|
||||
}
|
||||
|
||||
summaryValue.values.push(observeValue)
|
||||
summaryValue.sum += observeValue
|
||||
summaryValue.count++
|
||||
|
||||
// Limit size
|
||||
if (summaryValue.values.length > this.maxSize) {
|
||||
const removed = summaryValue.values.shift()
|
||||
if (removed !== undefined) {
|
||||
summaryValue.sum -= removed
|
||||
summaryValue.count--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer helper
|
||||
*/
|
||||
startTimer(labels: Labels = {}): () => number {
|
||||
const start = process.hrtime.bigint()
|
||||
return () => {
|
||||
const duration = Number(process.hrtime.bigint() - start) / 1_000_000 // ms
|
||||
this.observe(labels, duration)
|
||||
return duration
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentile
|
||||
*/
|
||||
getPercentile(labels: Labels, percentile: number): number | undefined {
|
||||
const key = this.labelsToKey(labels)
|
||||
const summaryValue = this.values.get(key)
|
||||
|
||||
if (!summaryValue || summaryValue.values.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sorted = [...summaryValue.values].sort((a, b) => a - b)
|
||||
const index = Math.ceil(percentile * sorted.length) - 1
|
||||
return sorted[Math.max(0, index)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary values
|
||||
*/
|
||||
get(labels: Labels = {}): SummaryValue | undefined {
|
||||
const key = this.labelsToKey(labels)
|
||||
return this.values.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the summary
|
||||
*/
|
||||
reset(labels?: Labels): void {
|
||||
if (labels) {
|
||||
const key = this.labelsToKey(labels)
|
||||
this.values.delete(key)
|
||||
} else {
|
||||
this.values.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values
|
||||
*/
|
||||
getValues(): SummaryValue[] {
|
||||
return Array.from(this.values.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured percentiles
|
||||
*/
|
||||
getPercentiles(): number[] {
|
||||
return [...this.percentiles]
|
||||
}
|
||||
|
||||
private labelsToKey(labels: Labels): string {
|
||||
return JSON.stringify(labels)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version with pre-defined labels
|
||||
*/
|
||||
labels(labels: Labels): {
|
||||
observe: (value: number) => void
|
||||
startTimer: () => () => number
|
||||
} {
|
||||
return {
|
||||
observe: (value: number) => this.observe(labels, value),
|
||||
startTimer: () => this.startTimer(labels),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Metrics registry
|
||||
*/
|
||||
export class MetricsRegistry {
|
||||
private metrics: Map<string, Counter | Gauge | Histogram | Summary> = new Map()
|
||||
private prefix: string
|
||||
private defaultLabels: Labels
|
||||
|
||||
constructor(options: { prefix?: string; defaultLabels?: Labels } = {}) {
|
||||
this.prefix = options.prefix || ''
|
||||
this.defaultLabels = options.defaultLabels || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a metric
|
||||
*/
|
||||
register<T extends Counter | Gauge | Histogram | Summary>(metric: T): T {
|
||||
const fullName = this.prefix ? `${this.prefix}_${metric.name}` : metric.name
|
||||
this.metrics.set(fullName, metric)
|
||||
return metric
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a metric
|
||||
*/
|
||||
get(name: string): Counter | Gauge | Histogram | Summary | undefined {
|
||||
const fullName = this.prefix ? `${this.prefix}_${name}` : name
|
||||
return this.metrics.get(fullName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a metric
|
||||
*/
|
||||
remove(name: string): boolean {
|
||||
const fullName = this.prefix ? `${this.prefix}_${name}` : name
|
||||
return this.metrics.delete(fullName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all metrics
|
||||
*/
|
||||
resetAll(): void {
|
||||
for (const metric of this.metrics.values()) {
|
||||
metric.reset()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return metrics in Prometheus format
|
||||
*/
|
||||
async metrics(): Promise<string> {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const [name, metric] of this.metrics) {
|
||||
lines.push(`# HELP ${name} ${metric.help}`)
|
||||
lines.push(`# TYPE ${name} ${metric.type}`)
|
||||
|
||||
if (metric instanceof Counter || metric instanceof Gauge) {
|
||||
for (const value of metric.getValues()) {
|
||||
const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels })
|
||||
lines.push(`${name}${labelsStr} ${value.value}`)
|
||||
}
|
||||
} else if (metric instanceof Histogram) {
|
||||
for (const value of metric.getValues()) {
|
||||
const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels })
|
||||
const buckets = metric.getBuckets()
|
||||
|
||||
for (const bucket of buckets) {
|
||||
const bucketLabels = this.formatLabels({
|
||||
...this.defaultLabels,
|
||||
...value.labels,
|
||||
le: String(bucket),
|
||||
})
|
||||
lines.push(`${name}_bucket${bucketLabels} ${value.buckets.get(bucket) ?? 0}`)
|
||||
}
|
||||
|
||||
// +Inf bucket
|
||||
const infLabels = this.formatLabels({
|
||||
...this.defaultLabels,
|
||||
...value.labels,
|
||||
le: '+Inf',
|
||||
})
|
||||
lines.push(`${name}_bucket${infLabels} ${value.count}`)
|
||||
lines.push(`${name}_sum${labelsStr} ${value.sum}`)
|
||||
lines.push(`${name}_count${labelsStr} ${value.count}`)
|
||||
}
|
||||
} else if (metric instanceof Summary) {
|
||||
for (const value of metric.getValues()) {
|
||||
const labelsStr = this.formatLabels({ ...this.defaultLabels, ...value.labels })
|
||||
|
||||
for (const percentile of metric.getPercentiles()) {
|
||||
const quantileLabels = this.formatLabels({
|
||||
...this.defaultLabels,
|
||||
...value.labels,
|
||||
quantile: String(percentile),
|
||||
})
|
||||
const percentileValue = metric.getPercentile(value.labels, percentile)
|
||||
if (percentileValue !== undefined) {
|
||||
lines.push(`${name}${quantileLabels} ${percentileValue}`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`${name}_sum${labelsStr} ${value.sum}`)
|
||||
lines.push(`${name}_count${labelsStr} ${value.count}`)
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return content type for Prometheus
|
||||
*/
|
||||
contentType(): string {
|
||||
return 'text/plain; version=0.0.4; charset=utf-8'
|
||||
}
|
||||
|
||||
private formatLabels(labels: Labels): string {
|
||||
const entries = Object.entries(labels)
|
||||
if (entries.length === 0) return ''
|
||||
|
||||
const formatted = entries.map(([k, v]) => `${k}="${this.escapeLabel(v)}"`).join(',')
|
||||
return `{${formatted}}`
|
||||
}
|
||||
|
||||
private escapeLabel(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
||||
}
|
||||
}
|
||||
|
||||
// === Default Baileys Metrics ===
|
||||
|
||||
/**
|
||||
* Global registry for Baileys metrics
|
||||
*/
|
||||
export const baileysMetrics = new MetricsRegistry({ prefix: 'baileys' })
|
||||
|
||||
/**
|
||||
* Pre-defined metrics for Baileys
|
||||
*/
|
||||
export const metrics = {
|
||||
// Connection
|
||||
connectionAttempts: baileysMetrics.register(
|
||||
new Counter('connection_attempts_total', 'Total connection attempts', ['status'])
|
||||
),
|
||||
connectionState: baileysMetrics.register(
|
||||
new Gauge('connection_state', 'Current connection state (0=disconnected, 1=connected)', ['instance'])
|
||||
),
|
||||
connectionDuration: baileysMetrics.register(
|
||||
new Gauge('connection_duration_seconds', 'Current connection duration in seconds', ['instance'])
|
||||
),
|
||||
|
||||
// Messages
|
||||
messagesSent: baileysMetrics.register(
|
||||
new Counter('messages_sent_total', 'Total messages sent', ['type'])
|
||||
),
|
||||
messagesReceived: baileysMetrics.register(
|
||||
new Counter('messages_received_total', 'Total messages received', ['type'])
|
||||
),
|
||||
messageLatency: baileysMetrics.register(
|
||||
new Histogram('message_latency_ms', 'Message send latency in ms', ['type'], [10, 50, 100, 250, 500, 1000, 2500, 5000])
|
||||
),
|
||||
|
||||
// Media
|
||||
mediaUploads: baileysMetrics.register(
|
||||
new Counter('media_uploads_total', 'Total media uploads', ['type', 'status'])
|
||||
),
|
||||
mediaDownloads: baileysMetrics.register(
|
||||
new Counter('media_downloads_total', 'Total media downloads', ['type', 'status'])
|
||||
),
|
||||
mediaSize: baileysMetrics.register(
|
||||
new Histogram('media_size_bytes', 'Media size in bytes', ['type', 'direction'], [1024, 10240, 102400, 1048576, 10485760])
|
||||
),
|
||||
|
||||
// Errors
|
||||
errors: baileysMetrics.register(
|
||||
new Counter('errors_total', 'Total errors', ['category', 'code'])
|
||||
),
|
||||
|
||||
// Retries
|
||||
retries: baileysMetrics.register(
|
||||
new Counter('retries_total', 'Total retries', ['operation'])
|
||||
),
|
||||
retryLatency: baileysMetrics.register(
|
||||
new Histogram('retry_latency_ms', 'Retry latency in ms', ['operation'])
|
||||
),
|
||||
|
||||
// Socket
|
||||
socketEvents: baileysMetrics.register(
|
||||
new Counter('socket_events_total', 'Total socket events', ['event'])
|
||||
),
|
||||
socketLatency: baileysMetrics.register(
|
||||
new Histogram('socket_latency_ms', 'Socket operation latency in ms', ['operation'])
|
||||
),
|
||||
|
||||
// Encryption
|
||||
encryptionOperations: baileysMetrics.register(
|
||||
new Counter('encryption_operations_total', 'Total encryption operations', ['operation'])
|
||||
),
|
||||
|
||||
// Cache
|
||||
cacheHits: baileysMetrics.register(new Counter('cache_hits_total', 'Total cache hits', ['cache'])),
|
||||
cacheMisses: baileysMetrics.register(new Counter('cache_misses_total', 'Total cache misses', ['cache'])),
|
||||
cacheSize: baileysMetrics.register(new Gauge('cache_size', 'Current cache size', ['cache'])),
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create HTTP metrics endpoint
|
||||
*/
|
||||
export function createMetricsHandler(registry: MetricsRegistry = baileysMetrics) {
|
||||
return async (_req: unknown, res: { setHeader: (name: string, value: string) => void; end: (body: string) => void }) => {
|
||||
const metricsOutput = await registry.metrics()
|
||||
res.setHeader('Content-Type', registry.contentType())
|
||||
res.end(metricsOutput)
|
||||
}
|
||||
}
|
||||
|
||||
export default baileysMetrics
|
||||
@@ -0,0 +1,636 @@
|
||||
/**
|
||||
* Smart Retry Logic
|
||||
*
|
||||
* Provides:
|
||||
* - Exponential backoff
|
||||
* - Jitter to avoid thundering herd
|
||||
* - Configurable max attempts
|
||||
* - Customizable retry predicates
|
||||
* - Circuit breaker integration
|
||||
* - Event hooks
|
||||
* - Cancellation support
|
||||
*
|
||||
* @module Utils/retry-utils
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { metrics } from './prometheus-metrics.js'
|
||||
import type { CircuitBreaker } from './circuit-breaker.js'
|
||||
|
||||
/**
|
||||
* Backoff strategies
|
||||
*/
|
||||
export type BackoffStrategy = 'exponential' | 'linear' | 'constant' | 'fibonacci'
|
||||
|
||||
/**
|
||||
* Retry configuration options
|
||||
*/
|
||||
export interface RetryOptions {
|
||||
/** Maximum number of attempts (default: 3) */
|
||||
maxAttempts?: number
|
||||
/** Base delay in ms (default: 1000) */
|
||||
baseDelay?: number
|
||||
/** Maximum delay in ms (default: 30000) */
|
||||
maxDelay?: number
|
||||
/** Backoff strategy (default: exponential) */
|
||||
backoffStrategy?: BackoffStrategy
|
||||
/** Multiplier for exponential backoff (default: 2) */
|
||||
backoffMultiplier?: number
|
||||
/** Jitter percentage (0-1, default: 0.1) */
|
||||
jitter?: number
|
||||
/** Function to determine if should retry */
|
||||
shouldRetry?: (error: Error, attempt: number) => boolean | Promise<boolean>
|
||||
/** Timeout per attempt in ms */
|
||||
timeout?: number
|
||||
/** Operation name for metrics */
|
||||
operationName?: string
|
||||
/** Collect metrics */
|
||||
collectMetrics?: boolean
|
||||
/** Circuit breaker for integration */
|
||||
circuitBreaker?: CircuitBreaker
|
||||
/** Callback before each retry */
|
||||
onRetry?: (error: Error, attempt: number, delay: number) => void | Promise<void>
|
||||
/** Callback on success */
|
||||
onSuccess?: (result: unknown, attempt: number) => void
|
||||
/** Callback on final failure */
|
||||
onFailure?: (error: Error, attempts: number) => void
|
||||
/** Signal for cancellation */
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of operation with retry
|
||||
*/
|
||||
export interface RetryResult<T> {
|
||||
success: boolean
|
||||
result?: T
|
||||
error?: Error
|
||||
attempts: number
|
||||
totalDuration: number
|
||||
lastAttemptDuration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry context
|
||||
*/
|
||||
export interface RetryContext {
|
||||
attempt: number
|
||||
maxAttempts: number
|
||||
lastError?: Error
|
||||
startTime: number
|
||||
aborted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry exhausted error
|
||||
*/
|
||||
export class RetryExhaustedError extends Error {
|
||||
constructor(
|
||||
public readonly originalError: Error,
|
||||
public readonly attempts: number,
|
||||
public readonly operationName?: string
|
||||
) {
|
||||
super(
|
||||
`Retry exhausted after ${attempts} attempts${operationName ? ` for "${operationName}"` : ''}: ${originalError.message}`
|
||||
)
|
||||
this.name = 'RetryExhaustedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort error
|
||||
*/
|
||||
export class RetryAbortedError extends Error {
|
||||
constructor(public readonly attempt: number) {
|
||||
super(`Retry aborted at attempt ${attempt}`)
|
||||
this.name = 'RetryAbortedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delay based on strategy
|
||||
*/
|
||||
export function calculateDelay(
|
||||
attempt: number,
|
||||
baseDelay: number,
|
||||
maxDelay: number,
|
||||
strategy: BackoffStrategy,
|
||||
multiplier: number,
|
||||
jitter: number
|
||||
): number {
|
||||
let delay: number
|
||||
|
||||
switch (strategy) {
|
||||
case 'exponential':
|
||||
delay = baseDelay * Math.pow(multiplier, attempt - 1)
|
||||
break
|
||||
|
||||
case 'linear':
|
||||
delay = baseDelay * attempt
|
||||
break
|
||||
|
||||
case 'constant':
|
||||
delay = baseDelay
|
||||
break
|
||||
|
||||
case 'fibonacci': {
|
||||
const fib = fibonacciNumber(attempt)
|
||||
delay = baseDelay * fib
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
delay = baseDelay
|
||||
}
|
||||
|
||||
// Apply max delay cap
|
||||
delay = Math.min(delay, maxDelay)
|
||||
|
||||
// Apply jitter
|
||||
if (jitter > 0) {
|
||||
const jitterAmount = delay * jitter
|
||||
delay = delay + (Math.random() * 2 - 1) * jitterAmount
|
||||
}
|
||||
|
||||
return Math.max(0, Math.round(delay))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Fibonacci number
|
||||
*/
|
||||
function fibonacciNumber(n: number): number {
|
||||
if (n <= 1) return 1
|
||||
let a = 1,
|
||||
b = 1
|
||||
for (let i = 2; i < n; i++) {
|
||||
const c = a + b
|
||||
a = b
|
||||
b = c
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep with abort support
|
||||
*/
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timer)
|
||||
reject(new RetryAbortedError(0))
|
||||
return
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new RetryAbortedError(0))
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', abortHandler, { once: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with timeout
|
||||
*/
|
||||
async function executeWithTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
timeout: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let completed = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!completed) {
|
||||
completed = true
|
||||
reject(new Error(`Operation timed out after ${timeout}ms`))
|
||||
}
|
||||
}, timeout)
|
||||
|
||||
if (signal?.aborted) {
|
||||
clearTimeout(timer)
|
||||
reject(new RetryAbortedError(0))
|
||||
return
|
||||
}
|
||||
|
||||
operation()
|
||||
.then((result) => {
|
||||
if (!completed) {
|
||||
completed = true
|
||||
clearTimeout(timer)
|
||||
resolve(result)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!completed) {
|
||||
completed = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Main retry function
|
||||
*/
|
||||
export async function retry<T>(
|
||||
operation: (context: RetryContext) => T | Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<T> {
|
||||
const config = {
|
||||
maxAttempts: options.maxAttempts ?? 3,
|
||||
baseDelay: options.baseDelay ?? 1000,
|
||||
maxDelay: options.maxDelay ?? 30000,
|
||||
backoffStrategy: options.backoffStrategy ?? 'exponential',
|
||||
backoffMultiplier: options.backoffMultiplier ?? 2,
|
||||
jitter: options.jitter ?? 0.1,
|
||||
shouldRetry: options.shouldRetry ?? (() => true),
|
||||
timeout: options.timeout,
|
||||
operationName: options.operationName ?? 'operation',
|
||||
collectMetrics: options.collectMetrics ?? true,
|
||||
circuitBreaker: options.circuitBreaker,
|
||||
onRetry: options.onRetry ?? (() => {}),
|
||||
onSuccess: options.onSuccess ?? (() => {}),
|
||||
onFailure: options.onFailure ?? (() => {}),
|
||||
abortSignal: options.abortSignal,
|
||||
}
|
||||
|
||||
const context: RetryContext = {
|
||||
attempt: 0,
|
||||
maxAttempts: config.maxAttempts,
|
||||
startTime: Date.now(),
|
||||
aborted: false,
|
||||
}
|
||||
|
||||
let lastError: Error | undefined
|
||||
|
||||
// Check initial abort
|
||||
if (config.abortSignal?.aborted) {
|
||||
throw new RetryAbortedError(0)
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
|
||||
context.attempt = attempt
|
||||
|
||||
// Check abort
|
||||
if (config.abortSignal?.aborted) {
|
||||
context.aborted = true
|
||||
throw new RetryAbortedError(attempt)
|
||||
}
|
||||
|
||||
// Check circuit breaker
|
||||
if (config.circuitBreaker?.isOpen()) {
|
||||
throw new Error(`Circuit breaker "${config.circuitBreaker.getName()}" is open`)
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute operation
|
||||
let result: T
|
||||
|
||||
if (config.timeout) {
|
||||
result = await executeWithTimeout(
|
||||
() => Promise.resolve(operation(context)),
|
||||
config.timeout,
|
||||
config.abortSignal
|
||||
)
|
||||
} else {
|
||||
result = await operation(context)
|
||||
}
|
||||
|
||||
// Success
|
||||
if (config.collectMetrics) {
|
||||
metrics.retries.inc({ operation: config.operationName })
|
||||
}
|
||||
|
||||
config.onSuccess(result, attempt)
|
||||
return result
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
context.lastError = lastError
|
||||
|
||||
// Check if should retry
|
||||
const shouldRetry = await config.shouldRetry(lastError, attempt)
|
||||
|
||||
if (!shouldRetry || attempt >= config.maxAttempts) {
|
||||
// Final failure
|
||||
if (config.collectMetrics) {
|
||||
metrics.errors.inc({ category: 'retry', code: 'exhausted' })
|
||||
}
|
||||
|
||||
config.onFailure(lastError, attempt)
|
||||
|
||||
throw new RetryExhaustedError(lastError, attempt, config.operationName)
|
||||
}
|
||||
|
||||
// Calculate delay
|
||||
const delay = calculateDelay(
|
||||
attempt,
|
||||
config.baseDelay,
|
||||
config.maxDelay,
|
||||
config.backoffStrategy,
|
||||
config.backoffMultiplier,
|
||||
config.jitter
|
||||
)
|
||||
|
||||
// Retry callback
|
||||
await config.onRetry(lastError, attempt, delay)
|
||||
|
||||
if (config.collectMetrics) {
|
||||
metrics.retryLatency.observe({ operation: config.operationName }, delay)
|
||||
}
|
||||
|
||||
// Wait for delay
|
||||
await sleep(delay, config.abortSignal)
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here, but TypeScript needs this
|
||||
throw new RetryExhaustedError(lastError || new Error('Unknown error'), config.maxAttempts, config.operationName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry with detailed result
|
||||
*/
|
||||
export async function retryWithResult<T>(
|
||||
operation: (context: RetryContext) => T | Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<RetryResult<T>> {
|
||||
const startTime = Date.now()
|
||||
let attempts = 0
|
||||
let lastAttemptStart = startTime
|
||||
|
||||
try {
|
||||
const result = await retry(
|
||||
(context) => {
|
||||
attempts = context.attempt
|
||||
lastAttemptStart = Date.now()
|
||||
return operation(context)
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
attempts,
|
||||
totalDuration: Date.now() - startTime,
|
||||
lastAttemptDuration: Date.now() - lastAttemptStart,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error,
|
||||
attempts,
|
||||
totalDuration: Date.now() - startTime,
|
||||
lastAttemptDuration: Date.now() - lastAttemptStart,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create configured retry function
|
||||
*/
|
||||
export function createRetrier(defaultOptions: RetryOptions = {}) {
|
||||
return <T>(
|
||||
operation: (context: RetryContext) => T | Promise<T>,
|
||||
options?: RetryOptions
|
||||
): Promise<T> => {
|
||||
return retry(operation, { ...defaultOptions, ...options })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator to add retry to method
|
||||
*/
|
||||
export function withRetry(options: RetryOptions = {}) {
|
||||
return function (
|
||||
_target: unknown,
|
||||
propertyKey: string,
|
||||
descriptor: TypedPropertyDescriptor<(...args: unknown[]) => unknown>
|
||||
) {
|
||||
const originalMethod = descriptor.value
|
||||
if (!originalMethod) return descriptor
|
||||
|
||||
descriptor.value = async function (...args: unknown[]): Promise<unknown> {
|
||||
return retry(
|
||||
() => originalMethod.apply(this, args),
|
||||
{ ...options, operationName: options.operationName || propertyKey }
|
||||
)
|
||||
}
|
||||
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for function with retry
|
||||
*/
|
||||
export function retryable<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
options: RetryOptions = {}
|
||||
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
|
||||
return async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||
return retry(() => fn(...args), options) as Promise<ReturnType<T>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to manage retries with state
|
||||
*/
|
||||
export class RetryManager extends EventEmitter {
|
||||
private activeRetries: Map<string, { cancel: () => void; context: RetryContext }> = new Map()
|
||||
private defaultOptions: RetryOptions
|
||||
|
||||
constructor(defaultOptions: RetryOptions = {}) {
|
||||
super()
|
||||
this.defaultOptions = defaultOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with retry
|
||||
*/
|
||||
async execute<T>(
|
||||
id: string,
|
||||
operation: (context: RetryContext) => T | Promise<T>,
|
||||
options?: RetryOptions
|
||||
): Promise<T> {
|
||||
// Cancel previous retry with same ID
|
||||
this.cancel(id)
|
||||
|
||||
const abortController = new AbortController()
|
||||
const mergedOptions = { ...this.defaultOptions, ...options, abortSignal: abortController.signal }
|
||||
|
||||
const retryPromise = retry((context) => {
|
||||
this.activeRetries.set(id, {
|
||||
cancel: () => abortController.abort(),
|
||||
context,
|
||||
})
|
||||
this.emit('attempt', { id, attempt: context.attempt })
|
||||
return operation(context)
|
||||
}, mergedOptions)
|
||||
|
||||
try {
|
||||
const result = await retryPromise
|
||||
this.emit('success', { id })
|
||||
return result
|
||||
} catch (error) {
|
||||
this.emit('failure', { id, error })
|
||||
throw error
|
||||
} finally {
|
||||
this.activeRetries.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel in-progress retry
|
||||
*/
|
||||
cancel(id: string): boolean {
|
||||
const active = this.activeRetries.get(id)
|
||||
if (active) {
|
||||
active.cancel()
|
||||
this.activeRetries.delete(id)
|
||||
this.emit('cancelled', { id })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all retries
|
||||
*/
|
||||
cancelAll(): void {
|
||||
for (const [id, active] of this.activeRetries) {
|
||||
active.cancel()
|
||||
this.emit('cancelled', { id })
|
||||
}
|
||||
this.activeRetries.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is an active retry
|
||||
*/
|
||||
isActive(id: string): boolean {
|
||||
return this.activeRetries.has(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return active retry context
|
||||
*/
|
||||
getContext(id: string): RetryContext | undefined {
|
||||
return this.activeRetries.get(id)?.context
|
||||
}
|
||||
|
||||
/**
|
||||
* Return active retry IDs
|
||||
*/
|
||||
getActiveIds(): string[] {
|
||||
return Array.from(this.activeRetries.keys())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common predicates for shouldRetry
|
||||
*/
|
||||
export const retryPredicates = {
|
||||
/** Always retry (up to max attempts) */
|
||||
always: () => true,
|
||||
|
||||
/** Never retry */
|
||||
never: () => false,
|
||||
|
||||
/** Retry only on network errors */
|
||||
onNetworkError: (error: Error) => {
|
||||
const networkErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']
|
||||
return networkErrors.some((code) => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
|
||||
},
|
||||
|
||||
/** Retry only on specific errors */
|
||||
onErrorCodes:
|
||||
(codes: string[]) =>
|
||||
(error: Error): boolean => {
|
||||
return codes.some((code) => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
|
||||
},
|
||||
|
||||
/** Retry except on specific errors */
|
||||
exceptErrorCodes:
|
||||
(codes: string[]) =>
|
||||
(error: Error): boolean => {
|
||||
return !codes.some((code) => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code)
|
||||
},
|
||||
|
||||
/** Retry on HTTP 5xx errors or timeout */
|
||||
onServerError: (error: Error) => {
|
||||
const message = error.message.toLowerCase()
|
||||
return (
|
||||
message.includes('500') ||
|
||||
message.includes('502') ||
|
||||
message.includes('503') ||
|
||||
message.includes('504') ||
|
||||
message.includes('timeout')
|
||||
)
|
||||
},
|
||||
|
||||
/** Combine multiple predicates with OR */
|
||||
or:
|
||||
(...predicates: Array<(error: Error, attempt: number) => boolean>) =>
|
||||
(error: Error, attempt: number): boolean => {
|
||||
return predicates.some((p) => p(error, attempt))
|
||||
},
|
||||
|
||||
/** Combine multiple predicates with AND */
|
||||
and:
|
||||
(...predicates: Array<(error: Error, attempt: number) => boolean>) =>
|
||||
(error: Error, attempt: number): boolean => {
|
||||
return predicates.every((p) => p(error, attempt))
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-defined retry configurations
|
||||
*/
|
||||
export const retryConfigs = {
|
||||
/** Aggressive retry (many attempts, short delays) */
|
||||
aggressive: {
|
||||
maxAttempts: 10,
|
||||
baseDelay: 100,
|
||||
maxDelay: 5000,
|
||||
backoffStrategy: 'exponential' as const,
|
||||
jitter: 0.2,
|
||||
},
|
||||
|
||||
/** Conservative retry (few attempts, long delays) */
|
||||
conservative: {
|
||||
maxAttempts: 3,
|
||||
baseDelay: 2000,
|
||||
maxDelay: 60000,
|
||||
backoffStrategy: 'exponential' as const,
|
||||
jitter: 0.1,
|
||||
},
|
||||
|
||||
/** Fast retry (for short operations) */
|
||||
fast: {
|
||||
maxAttempts: 5,
|
||||
baseDelay: 50,
|
||||
maxDelay: 1000,
|
||||
backoffStrategy: 'linear' as const,
|
||||
jitter: 0.05,
|
||||
},
|
||||
|
||||
/** Retry for network operations */
|
||||
network: {
|
||||
maxAttempts: 5,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
backoffStrategy: 'exponential' as const,
|
||||
jitter: 0.1,
|
||||
shouldRetry: retryPredicates.onNetworkError,
|
||||
},
|
||||
}
|
||||
|
||||
export default retry
|
||||
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* Structured Logging System for InfiniteAPI
|
||||
*
|
||||
* Provides:
|
||||
* - Configurable log levels (trace, debug, info, warn, error, fatal)
|
||||
* - JSON formatting for log analysis
|
||||
* - Hierarchical context with child loggers
|
||||
* - External system integration via hooks
|
||||
* - Logging metrics support
|
||||
* - Sensitive data sanitization
|
||||
*
|
||||
* @module Utils/structured-logger
|
||||
*/
|
||||
|
||||
import type { ILogger } from './logger.js'
|
||||
|
||||
/**
|
||||
* Available log levels (ordered by severity)
|
||||
*/
|
||||
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent'
|
||||
|
||||
/**
|
||||
* Numeric values for each log level
|
||||
*/
|
||||
export const LOG_LEVEL_VALUES: Record<LogLevel, number> = {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
info: 30,
|
||||
warn: 40,
|
||||
error: 50,
|
||||
fatal: 60,
|
||||
silent: 100,
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured logger configuration
|
||||
*/
|
||||
export interface StructuredLoggerConfig {
|
||||
/** Minimum log level to record */
|
||||
level: LogLevel
|
||||
/** Service/component name */
|
||||
name?: string
|
||||
/** Additional context to include in all logs */
|
||||
context?: Record<string, unknown>
|
||||
/** Format as JSON (true) or human-readable text (false) */
|
||||
jsonFormat?: boolean
|
||||
/** Fields to sanitize (passwords, tokens, etc.) */
|
||||
redactFields?: string[]
|
||||
/** Hook for sending logs to external systems */
|
||||
externalHook?: (entry: LogEntry) => void | Promise<void>
|
||||
/** Include stack trace in errors */
|
||||
includeStackTrace?: boolean
|
||||
/** Timezone for timestamps (default: UTC) */
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured log entry
|
||||
*/
|
||||
export interface LogEntry {
|
||||
/** ISO 8601 timestamp */
|
||||
timestamp: string
|
||||
/** Log level */
|
||||
level: LogLevel
|
||||
/** Numeric level value */
|
||||
levelValue: number
|
||||
/** Main message */
|
||||
message: string
|
||||
/** Logger/component name */
|
||||
name?: string
|
||||
/** Additional context */
|
||||
context?: Record<string, unknown>
|
||||
/** Logged object data */
|
||||
data?: Record<string, unknown>
|
||||
/** Stack trace (for errors) */
|
||||
stack?: string
|
||||
/** Correlation ID for tracing */
|
||||
correlationId?: string
|
||||
/** Operation duration in ms (if applicable) */
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger metrics
|
||||
*/
|
||||
export interface LoggerMetrics {
|
||||
totalLogs: number
|
||||
logsByLevel: Record<LogLevel, number>
|
||||
errorsCount: number
|
||||
lastLogTimestamp?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Default fields to sanitize
|
||||
*/
|
||||
const DEFAULT_REDACT_FIELDS = [
|
||||
'password',
|
||||
'passwd',
|
||||
'secret',
|
||||
'token',
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'apiKey',
|
||||
'api_key',
|
||||
'authorization',
|
||||
'auth',
|
||||
'credentials',
|
||||
'privateKey',
|
||||
'private_key',
|
||||
]
|
||||
|
||||
/**
|
||||
* Structured Logger main class
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const logger = createStructuredLogger({
|
||||
* level: 'info',
|
||||
* name: 'my-service',
|
||||
* jsonFormat: true
|
||||
* })
|
||||
*
|
||||
* logger.info({ userId: '123' }, 'User logged in')
|
||||
* logger.error(new Error('Connection failed'))
|
||||
* ```
|
||||
*/
|
||||
export class StructuredLogger implements ILogger {
|
||||
private config: Required<StructuredLoggerConfig>
|
||||
private metrics: LoggerMetrics
|
||||
private childContext: Record<string, unknown> = {}
|
||||
|
||||
constructor(config: StructuredLoggerConfig) {
|
||||
this.config = {
|
||||
level: config.level,
|
||||
name: config.name || 'app',
|
||||
context: config.context || {},
|
||||
jsonFormat: config.jsonFormat ?? true,
|
||||
redactFields: [...DEFAULT_REDACT_FIELDS, ...(config.redactFields || [])],
|
||||
externalHook: config.externalHook || (() => {}),
|
||||
includeStackTrace: config.includeStackTrace ?? true,
|
||||
timezone: config.timezone || 'UTC',
|
||||
}
|
||||
|
||||
this.metrics = {
|
||||
totalLogs: 0,
|
||||
logsByLevel: {
|
||||
trace: 0,
|
||||
debug: 0,
|
||||
info: 0,
|
||||
warn: 0,
|
||||
error: 0,
|
||||
fatal: 0,
|
||||
silent: 0,
|
||||
},
|
||||
errorsCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current log level (ILogger compatibility)
|
||||
*/
|
||||
get level(): string {
|
||||
return this.config.level
|
||||
}
|
||||
|
||||
/**
|
||||
* Set log level
|
||||
*/
|
||||
set level(newLevel: string) {
|
||||
if (newLevel in LOG_LEVEL_VALUES) {
|
||||
this.config.level = newLevel as LogLevel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with additional context
|
||||
*/
|
||||
child(obj: Record<string, unknown>): StructuredLogger {
|
||||
const childLogger = new StructuredLogger({
|
||||
...this.config,
|
||||
context: { ...this.config.context, ...this.childContext, ...obj },
|
||||
})
|
||||
childLogger.childContext = { ...this.childContext, ...obj }
|
||||
return childLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if log level is enabled
|
||||
*/
|
||||
isLevelEnabled(level: LogLevel): boolean {
|
||||
return LOG_LEVEL_VALUES[level] >= LOG_LEVEL_VALUES[this.config.level]
|
||||
}
|
||||
|
||||
/**
|
||||
* Main logging method
|
||||
*/
|
||||
private log(level: LogLevel, obj: unknown, msg?: string): void {
|
||||
if (!this.isLevelEnabled(level)) {
|
||||
return
|
||||
}
|
||||
|
||||
const entry = this.createLogEntry(level, obj, msg)
|
||||
|
||||
// Update metrics
|
||||
this.updateMetrics(level)
|
||||
|
||||
// Output
|
||||
this.output(entry)
|
||||
|
||||
// External hook (async, non-blocking)
|
||||
if (this.config.externalHook) {
|
||||
Promise.resolve(this.config.externalHook(entry)).catch(() => {
|
||||
// Silently ignore hook errors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a structured log entry
|
||||
*/
|
||||
private createLogEntry(level: LogLevel, obj: unknown, msg?: string): LogEntry {
|
||||
const timestamp = new Date().toISOString()
|
||||
let message = msg || ''
|
||||
let data: Record<string, unknown> | undefined
|
||||
let stack: string | undefined
|
||||
|
||||
// Process object
|
||||
if (obj instanceof Error) {
|
||||
message = message || obj.message
|
||||
if (this.config.includeStackTrace && obj.stack) {
|
||||
stack = obj.stack
|
||||
}
|
||||
data = {
|
||||
errorName: obj.name,
|
||||
errorMessage: obj.message,
|
||||
...(obj as unknown as Record<string, unknown>),
|
||||
}
|
||||
} else if (typeof obj === 'object' && obj !== null) {
|
||||
data = this.sanitize(obj as Record<string, unknown>)
|
||||
if (!message && 'msg' in (obj as Record<string, unknown>)) {
|
||||
message = String((obj as Record<string, unknown>).msg)
|
||||
}
|
||||
} else if (typeof obj === 'string') {
|
||||
message = message || obj
|
||||
}
|
||||
|
||||
// Extract correlationId and durationMs if present
|
||||
const correlationId = data?.correlationId as string | undefined
|
||||
const durationMs = data?.durationMs as number | undefined
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
level,
|
||||
levelValue: LOG_LEVEL_VALUES[level],
|
||||
message,
|
||||
name: this.config.name,
|
||||
context: Object.keys(this.config.context).length > 0 ? this.config.context : undefined,
|
||||
data,
|
||||
stack,
|
||||
correlationId,
|
||||
durationMs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize sensitive data
|
||||
*/
|
||||
private sanitize(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
|
||||
if (this.config.redactFields.some((field) => lowerKey.includes(field.toLowerCase()))) {
|
||||
sanitized[key] = '[REDACTED]'
|
||||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
sanitized[key] = this.sanitize(value as Record<string, unknown>)
|
||||
} else {
|
||||
sanitized[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Update internal metrics
|
||||
*/
|
||||
private updateMetrics(level: LogLevel): void {
|
||||
this.metrics.totalLogs++
|
||||
this.metrics.logsByLevel[level]++
|
||||
this.metrics.lastLogTimestamp = new Date().toISOString()
|
||||
|
||||
if (level === 'error' || level === 'fatal') {
|
||||
this.metrics.errorsCount++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output log entry
|
||||
*/
|
||||
private output(entry: LogEntry): void {
|
||||
const output = this.config.jsonFormat ? JSON.stringify(entry) : this.formatText(entry)
|
||||
|
||||
switch (entry.level) {
|
||||
case 'trace':
|
||||
case 'debug':
|
||||
console.debug(output)
|
||||
break
|
||||
case 'info':
|
||||
console.info(output)
|
||||
break
|
||||
case 'warn':
|
||||
console.warn(output)
|
||||
break
|
||||
case 'error':
|
||||
case 'fatal':
|
||||
console.error(output)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format log as human-readable text
|
||||
*/
|
||||
private formatText(entry: LogEntry): string {
|
||||
const parts = [
|
||||
`[${entry.timestamp}]`,
|
||||
`[${entry.level.toUpperCase()}]`,
|
||||
entry.name ? `[${entry.name}]` : '',
|
||||
entry.correlationId ? `[${entry.correlationId}]` : '',
|
||||
entry.message,
|
||||
entry.durationMs !== undefined ? `(${entry.durationMs}ms)` : '',
|
||||
]
|
||||
|
||||
let text = parts.filter(Boolean).join(' ')
|
||||
|
||||
if (entry.data && Object.keys(entry.data).length > 0) {
|
||||
text += ` | ${JSON.stringify(entry.data)}`
|
||||
}
|
||||
|
||||
if (entry.stack) {
|
||||
text += `\n${entry.stack}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// Convenience methods for each log level
|
||||
|
||||
trace(obj: unknown, msg?: string): void {
|
||||
this.log('trace', obj, msg)
|
||||
}
|
||||
|
||||
debug(obj: unknown, msg?: string): void {
|
||||
this.log('debug', obj, msg)
|
||||
}
|
||||
|
||||
info(obj: unknown, msg?: string): void {
|
||||
this.log('info', obj, msg)
|
||||
}
|
||||
|
||||
warn(obj: unknown, msg?: string): void {
|
||||
this.log('warn', obj, msg)
|
||||
}
|
||||
|
||||
error(obj: unknown, msg?: string): void {
|
||||
this.log('error', obj, msg)
|
||||
}
|
||||
|
||||
fatal(obj: unknown, msg?: string): void {
|
||||
this.log('fatal', obj, msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log with temporary context
|
||||
*/
|
||||
withContext(context: Record<string, unknown>): StructuredLogger {
|
||||
return this.child(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log with correlation ID
|
||||
*/
|
||||
withCorrelationId(correlationId: string): StructuredLogger {
|
||||
return this.child({ correlationId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Log operation with duration tracking
|
||||
*/
|
||||
logOperation<T>(
|
||||
operationName: string,
|
||||
operation: () => T | Promise<T>,
|
||||
level: LogLevel = 'info'
|
||||
): T | Promise<T> {
|
||||
const startTime = Date.now()
|
||||
const contextLogger = this.child({ operation: operationName })
|
||||
|
||||
contextLogger.log(level, { event: 'operation_start' }, `Starting ${operationName}`)
|
||||
|
||||
const handleResult = (result: T): T => {
|
||||
const durationMs = Date.now() - startTime
|
||||
contextLogger.log(level, { event: 'operation_complete', durationMs }, `Completed ${operationName}`)
|
||||
return result
|
||||
}
|
||||
|
||||
const handleError = (error: Error): never => {
|
||||
const durationMs = Date.now() - startTime
|
||||
contextLogger.error({ event: 'operation_error', durationMs, error }, `Failed ${operationName}`)
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const result = operation()
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then(handleResult).catch(handleError) as Promise<T>
|
||||
}
|
||||
|
||||
return handleResult(result)
|
||||
} catch (error) {
|
||||
return handleError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logger metrics
|
||||
*/
|
||||
getMetrics(): LoggerMetrics {
|
||||
return { ...this.metrics }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset metrics
|
||||
*/
|
||||
resetMetrics(): void {
|
||||
this.metrics = {
|
||||
totalLogs: 0,
|
||||
logsByLevel: {
|
||||
trace: 0,
|
||||
debug: 0,
|
||||
info: 0,
|
||||
warn: 0,
|
||||
error: 0,
|
||||
fatal: 0,
|
||||
silent: 0,
|
||||
},
|
||||
errorsCount: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory to create structured logger
|
||||
*/
|
||||
export function createStructuredLogger(config: Partial<StructuredLoggerConfig> = {}): StructuredLogger {
|
||||
return new StructuredLogger({
|
||||
level: config.level || 'info',
|
||||
...config,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Default singleton logger
|
||||
*/
|
||||
let defaultLogger: StructuredLogger | null = null
|
||||
|
||||
export function getDefaultLogger(): StructuredLogger {
|
||||
if (!defaultLogger) {
|
||||
defaultLogger = createStructuredLogger({
|
||||
level: 'info',
|
||||
name: 'baileys',
|
||||
jsonFormat: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
}
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
export function setDefaultLogger(logger: StructuredLogger): void {
|
||||
defaultLogger = logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to measure execution time
|
||||
*/
|
||||
export function createTimer(): { elapsed: () => number; elapsedMs: () => string } {
|
||||
const start = process.hrtime.bigint()
|
||||
return {
|
||||
elapsed: () => Number(process.hrtime.bigint() - start) / 1_000_000,
|
||||
elapsedMs: () => `${(Number(process.hrtime.bigint() - start) / 1_000_000).toFixed(2)}ms`,
|
||||
}
|
||||
}
|
||||
|
||||
export default StructuredLogger
|
||||
@@ -0,0 +1,657 @@
|
||||
/**
|
||||
* Request Tracing Context
|
||||
*
|
||||
* Provides:
|
||||
* - Unique trace ID generation
|
||||
* - Context propagation between operations
|
||||
* - Correlation IDs for request tracking
|
||||
* - Performance timing
|
||||
* - Span tracking for nested operations
|
||||
* - Baggage for contextual data
|
||||
*
|
||||
* @module Utils/trace-context
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
|
||||
/**
|
||||
* Trace identifiers
|
||||
*/
|
||||
export interface TraceIds {
|
||||
/** Unique trace ID (16 bytes hex) */
|
||||
traceId: string
|
||||
/** Current span ID (8 bytes hex) */
|
||||
spanId: string
|
||||
/** Parent span ID (optional) */
|
||||
parentSpanId?: string
|
||||
/** Correlation ID for logging */
|
||||
correlationId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Baggage data (propagated context)
|
||||
*/
|
||||
export type Baggage = Record<string, string | number | boolean>
|
||||
|
||||
/**
|
||||
* Span status
|
||||
*/
|
||||
export type SpanStatus = 'unset' | 'ok' | 'error'
|
||||
|
||||
/**
|
||||
* Span represents a unit of work
|
||||
*/
|
||||
export interface Span {
|
||||
/** Operation name */
|
||||
name: string
|
||||
/** Trace identifiers */
|
||||
traceIds: TraceIds
|
||||
/** Start timestamp (ms) */
|
||||
startTime: number
|
||||
/** End timestamp (ms) */
|
||||
endTime?: number
|
||||
/** Duration in ms */
|
||||
duration?: number
|
||||
/** Span status */
|
||||
status: SpanStatus
|
||||
/** Span attributes */
|
||||
attributes: Record<string, unknown>
|
||||
/** Events occurred during the span */
|
||||
events: SpanEvent[]
|
||||
/** Whether the span has ended */
|
||||
ended: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Event within a span
|
||||
*/
|
||||
export interface SpanEvent {
|
||||
/** Event name */
|
||||
name: string
|
||||
/** Event timestamp */
|
||||
timestamp: number
|
||||
/** Event attributes */
|
||||
attributes?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete trace context
|
||||
*/
|
||||
export interface TraceContext {
|
||||
/** Trace identifiers */
|
||||
traceIds: TraceIds
|
||||
/** Baggage (propagated data) */
|
||||
baggage: Baggage
|
||||
/** Current span */
|
||||
currentSpan?: Span
|
||||
/** Span stack (for nested spans) */
|
||||
spanStack: Span[]
|
||||
/** Context creation timestamp */
|
||||
createdAt: number
|
||||
/** Additional metadata */
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a new context
|
||||
*/
|
||||
export interface CreateContextOptions {
|
||||
/** Existing trace ID (for propagation) */
|
||||
traceId?: string
|
||||
/** Parent span ID */
|
||||
parentSpanId?: string
|
||||
/** Existing correlation ID */
|
||||
correlationId?: string
|
||||
/** Initial baggage */
|
||||
baggage?: Baggage
|
||||
/** Initial metadata */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a span
|
||||
*/
|
||||
export interface CreateSpanOptions {
|
||||
/** Span name */
|
||||
name: string
|
||||
/** Initial attributes */
|
||||
attributes?: Record<string, unknown>
|
||||
/** Whether to be a child of the current span */
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Async storage for trace context
|
||||
*/
|
||||
const traceStorage = new AsyncLocalStorage<TraceContext>()
|
||||
|
||||
/**
|
||||
* Generate a random hexadecimal ID
|
||||
*/
|
||||
function generateId(bytes: number): string {
|
||||
return randomBytes(bytes).toString('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a trace ID (16 bytes = 32 chars hex)
|
||||
*/
|
||||
export function generateTraceId(): string {
|
||||
return generateId(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a span ID (8 bytes = 16 chars hex)
|
||||
*/
|
||||
export function generateSpanId(): string {
|
||||
return generateId(8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a more readable correlation ID
|
||||
*/
|
||||
export function generateCorrelationId(): string {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = generateId(4)
|
||||
return `${timestamp}-${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new trace context
|
||||
*/
|
||||
export function createTraceContext(options: CreateContextOptions = {}): TraceContext {
|
||||
const traceId = options.traceId || generateTraceId()
|
||||
const spanId = generateSpanId()
|
||||
const correlationId = options.correlationId || generateCorrelationId()
|
||||
|
||||
return {
|
||||
traceIds: {
|
||||
traceId,
|
||||
spanId,
|
||||
parentSpanId: options.parentSpanId,
|
||||
correlationId,
|
||||
},
|
||||
baggage: options.baggage || {},
|
||||
spanStack: [],
|
||||
createdAt: Date.now(),
|
||||
metadata: options.metadata || {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current trace context
|
||||
*/
|
||||
export function getCurrentContext(): TraceContext | undefined {
|
||||
return traceStorage.getStore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current trace context or create a new one
|
||||
*/
|
||||
export function getOrCreateContext(): TraceContext {
|
||||
const existing = getCurrentContext()
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
return createTraceContext()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute function with trace context
|
||||
*/
|
||||
export function runWithContext<T>(context: TraceContext, fn: () => T): T {
|
||||
return traceStorage.run(context, fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute function with new trace context
|
||||
*/
|
||||
export function runWithNewContext<T>(options: CreateContextOptions, fn: () => T): T {
|
||||
const context = createTraceContext(options)
|
||||
return runWithContext(context, fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute async function with trace context
|
||||
*/
|
||||
export async function runWithContextAsync<T>(
|
||||
context: TraceContext,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return traceStorage.run(context, fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new span
|
||||
*/
|
||||
export function createSpan(options: CreateSpanOptions): Span {
|
||||
const context = getCurrentContext()
|
||||
const parentSpan = context?.currentSpan
|
||||
|
||||
const span: Span = {
|
||||
name: options.name,
|
||||
traceIds: {
|
||||
traceId: context?.traceIds.traceId || generateTraceId(),
|
||||
spanId: generateSpanId(),
|
||||
parentSpanId: options.asChild && parentSpan ? parentSpan.traceIds.spanId : undefined,
|
||||
correlationId: context?.traceIds.correlationId || generateCorrelationId(),
|
||||
},
|
||||
startTime: Date.now(),
|
||||
status: 'unset',
|
||||
attributes: options.attributes || {},
|
||||
events: [],
|
||||
ended: false,
|
||||
}
|
||||
|
||||
return span
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a span in the current context
|
||||
*/
|
||||
export function startSpan(options: CreateSpanOptions): Span {
|
||||
const context = getOrCreateContext()
|
||||
const span = createSpan({ ...options, asChild: true })
|
||||
|
||||
// Push current span to stack and set new one as current
|
||||
if (context.currentSpan) {
|
||||
context.spanStack.push(context.currentSpan)
|
||||
}
|
||||
context.currentSpan = span
|
||||
|
||||
return span
|
||||
}
|
||||
|
||||
/**
|
||||
* End a span
|
||||
*/
|
||||
export function endSpan(span: Span, status?: SpanStatus): void {
|
||||
if (span.ended) {
|
||||
return
|
||||
}
|
||||
|
||||
span.endTime = Date.now()
|
||||
span.duration = span.endTime - span.startTime
|
||||
span.status = status || 'ok'
|
||||
span.ended = true
|
||||
|
||||
// Pop span from stack in context
|
||||
const context = getCurrentContext()
|
||||
if (context && context.currentSpan === span) {
|
||||
context.currentSpan = context.spanStack.pop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event to a span
|
||||
*/
|
||||
export function addSpanEvent(span: Span, name: string, attributes?: Record<string, unknown>): void {
|
||||
if (span.ended) {
|
||||
return
|
||||
}
|
||||
|
||||
span.events.push({
|
||||
name,
|
||||
timestamp: Date.now(),
|
||||
attributes,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set attributes on a span
|
||||
*/
|
||||
export function setSpanAttributes(span: Span, attributes: Record<string, unknown>): void {
|
||||
if (span.ended) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.assign(span.attributes, attributes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark span as error
|
||||
*/
|
||||
export function setSpanError(span: Span, error: Error): void {
|
||||
if (span.ended) {
|
||||
return
|
||||
}
|
||||
|
||||
span.status = 'error'
|
||||
span.attributes.error = true
|
||||
span.attributes.errorMessage = error.message
|
||||
span.attributes.errorName = error.name
|
||||
if (error.stack) {
|
||||
span.attributes.errorStack = error.stack
|
||||
}
|
||||
|
||||
addSpanEvent(span, 'exception', {
|
||||
'exception.type': error.name,
|
||||
'exception.message': error.message,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator for automatic function tracing
|
||||
*/
|
||||
export function traced(name?: string) {
|
||||
return function <T extends (...args: unknown[]) => unknown>(
|
||||
_target: unknown,
|
||||
propertyKey: string,
|
||||
descriptor: TypedPropertyDescriptor<T>
|
||||
): TypedPropertyDescriptor<T> {
|
||||
const originalMethod = descriptor.value
|
||||
if (!originalMethod) {
|
||||
return descriptor
|
||||
}
|
||||
|
||||
const spanName = name || propertyKey
|
||||
|
||||
descriptor.value = function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
|
||||
const span = startSpan({ name: spanName })
|
||||
|
||||
try {
|
||||
const result = originalMethod.apply(this, args) as ReturnType<T>
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then((value) => {
|
||||
endSpan(span, 'ok')
|
||||
return value
|
||||
})
|
||||
.catch((error) => {
|
||||
setSpanError(span, error as Error)
|
||||
endSpan(span, 'error')
|
||||
throw error
|
||||
}) as ReturnType<T>
|
||||
}
|
||||
|
||||
endSpan(span, 'ok')
|
||||
return result
|
||||
} catch (error) {
|
||||
setSpanError(span, error as Error)
|
||||
endSpan(span, 'error')
|
||||
throw error
|
||||
}
|
||||
} as T
|
||||
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for tracing a function
|
||||
*/
|
||||
export function traceFunction<T extends (...args: unknown[]) => unknown>(
|
||||
name: string,
|
||||
fn: T
|
||||
): T {
|
||||
return function (this: unknown, ...args: Parameters<T>): ReturnType<T> {
|
||||
const span = startSpan({ name })
|
||||
|
||||
try {
|
||||
const result = fn.apply(this, args) as ReturnType<T>
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result
|
||||
.then((value) => {
|
||||
endSpan(span, 'ok')
|
||||
return value
|
||||
})
|
||||
.catch((error) => {
|
||||
setSpanError(span, error as Error)
|
||||
endSpan(span, 'error')
|
||||
throw error
|
||||
}) as ReturnType<T>
|
||||
}
|
||||
|
||||
endSpan(span, 'ok')
|
||||
return result
|
||||
} catch (error) {
|
||||
setSpanError(span, error as Error)
|
||||
endSpan(span, 'error')
|
||||
throw error
|
||||
}
|
||||
} as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with automatic span
|
||||
*/
|
||||
export async function withSpan<T>(
|
||||
name: string,
|
||||
operation: (span: Span) => Promise<T>,
|
||||
attributes?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const span = startSpan({ name, attributes })
|
||||
|
||||
try {
|
||||
const result = await operation(span)
|
||||
endSpan(span, 'ok')
|
||||
return result
|
||||
} catch (error) {
|
||||
setSpanError(span, error as Error)
|
||||
endSpan(span, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute sync operation with automatic span
|
||||
*/
|
||||
export function withSpanSync<T>(
|
||||
name: string,
|
||||
operation: (span: Span) => T,
|
||||
attributes?: Record<string, unknown>
|
||||
): T {
|
||||
const span = startSpan({ name, attributes })
|
||||
|
||||
try {
|
||||
const result = operation(span)
|
||||
endSpan(span, 'ok')
|
||||
return result
|
||||
} catch (error) {
|
||||
setSpanError(span, error as Error)
|
||||
endSpan(span, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// === Baggage Management ===
|
||||
|
||||
/**
|
||||
* Set item in baggage
|
||||
*/
|
||||
export function setBaggage(key: string, value: string | number | boolean): void {
|
||||
const context = getCurrentContext()
|
||||
if (context) {
|
||||
context.baggage[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item from baggage
|
||||
*/
|
||||
export function getBaggage(key: string): string | number | boolean | undefined {
|
||||
const context = getCurrentContext()
|
||||
return context?.baggage[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all baggage
|
||||
*/
|
||||
export function getAllBaggage(): Baggage {
|
||||
const context = getCurrentContext()
|
||||
return context?.baggage || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from baggage
|
||||
*/
|
||||
export function removeBaggage(key: string): void {
|
||||
const context = getCurrentContext()
|
||||
if (context) {
|
||||
delete context.baggage[key]
|
||||
}
|
||||
}
|
||||
|
||||
// === Header Utilities ===
|
||||
|
||||
/**
|
||||
* Standard headers for trace propagation
|
||||
*/
|
||||
export const TRACE_HEADERS = {
|
||||
TRACE_ID: 'x-trace-id',
|
||||
SPAN_ID: 'x-span-id',
|
||||
PARENT_SPAN_ID: 'x-parent-span-id',
|
||||
CORRELATION_ID: 'x-correlation-id',
|
||||
BAGGAGE: 'baggage',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Inject context into HTTP headers
|
||||
*/
|
||||
export function injectTraceHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const context = getCurrentContext()
|
||||
if (!context) {
|
||||
return headers
|
||||
}
|
||||
|
||||
const result = { ...headers }
|
||||
result[TRACE_HEADERS.TRACE_ID] = context.traceIds.traceId
|
||||
result[TRACE_HEADERS.SPAN_ID] = context.traceIds.spanId
|
||||
result[TRACE_HEADERS.CORRELATION_ID] = context.traceIds.correlationId
|
||||
|
||||
if (context.traceIds.parentSpanId) {
|
||||
result[TRACE_HEADERS.PARENT_SPAN_ID] = context.traceIds.parentSpanId
|
||||
}
|
||||
|
||||
// Baggage as key=value list
|
||||
if (Object.keys(context.baggage).length > 0) {
|
||||
result[TRACE_HEADERS.BAGGAGE] = Object.entries(context.baggage)
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract context from HTTP headers
|
||||
*/
|
||||
export function extractTraceHeaders(headers: Record<string, string | undefined>): CreateContextOptions {
|
||||
const options: CreateContextOptions = {}
|
||||
|
||||
if (headers[TRACE_HEADERS.TRACE_ID]) {
|
||||
options.traceId = headers[TRACE_HEADERS.TRACE_ID]
|
||||
}
|
||||
|
||||
if (headers[TRACE_HEADERS.PARENT_SPAN_ID]) {
|
||||
options.parentSpanId = headers[TRACE_HEADERS.PARENT_SPAN_ID]
|
||||
}
|
||||
|
||||
if (headers[TRACE_HEADERS.CORRELATION_ID]) {
|
||||
options.correlationId = headers[TRACE_HEADERS.CORRELATION_ID]
|
||||
}
|
||||
|
||||
// Parse baggage
|
||||
if (headers[TRACE_HEADERS.BAGGAGE]) {
|
||||
options.baggage = {}
|
||||
const pairs = headers[TRACE_HEADERS.BAGGAGE].split(',')
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=')
|
||||
if (key && value) {
|
||||
options.baggage[key.trim()] = decodeURIComponent(value.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Export trace context for serialization
|
||||
*/
|
||||
export function exportContext(context: TraceContext): string {
|
||||
return JSON.stringify({
|
||||
traceIds: context.traceIds,
|
||||
baggage: context.baggage,
|
||||
metadata: context.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Import trace context from serialized string
|
||||
*/
|
||||
export function importContext(serialized: string): CreateContextOptions {
|
||||
try {
|
||||
const data = JSON.parse(serialized)
|
||||
return {
|
||||
traceId: data.traceIds?.traceId,
|
||||
parentSpanId: data.traceIds?.spanId,
|
||||
correlationId: data.traceIds?.correlationId,
|
||||
baggage: data.baggage,
|
||||
metadata: data.metadata,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// === Timer Utilities ===
|
||||
|
||||
/**
|
||||
* High precision timer
|
||||
*/
|
||||
export interface PrecisionTimer {
|
||||
/** Return elapsed time in milliseconds */
|
||||
elapsed(): number
|
||||
/** Return formatted elapsed time */
|
||||
elapsedFormatted(): string
|
||||
/** Stop the timer and return duration */
|
||||
stop(): number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a high precision timer
|
||||
*/
|
||||
export function createPrecisionTimer(): PrecisionTimer {
|
||||
const start = process.hrtime.bigint()
|
||||
let stopped = false
|
||||
let finalDuration = 0
|
||||
|
||||
return {
|
||||
elapsed(): number {
|
||||
if (stopped) return finalDuration
|
||||
return Number(process.hrtime.bigint() - start) / 1_000_000
|
||||
},
|
||||
elapsedFormatted(): string {
|
||||
const ms = this.elapsed()
|
||||
if (ms < 1) return `${(ms * 1000).toFixed(2)}µs`
|
||||
if (ms < 1000) return `${ms.toFixed(2)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
},
|
||||
stop(): number {
|
||||
if (!stopped) {
|
||||
finalDuration = Number(process.hrtime.bigint() - start) / 1_000_000
|
||||
stopped = true
|
||||
}
|
||||
return finalDuration
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createTraceContext,
|
||||
getCurrentContext,
|
||||
getOrCreateContext,
|
||||
runWithContext,
|
||||
runWithNewContext,
|
||||
createSpan,
|
||||
startSpan,
|
||||
endSpan,
|
||||
withSpan,
|
||||
withSpanSync,
|
||||
injectTraceHeaders,
|
||||
extractTraceHeaders,
|
||||
createPrecisionTimer,
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
/**
|
||||
* Testes unitários para baileys-event-stream.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
|
||||
import {
|
||||
BaileysEventStream,
|
||||
createEventStream,
|
||||
eventFilters,
|
||||
eventTransformers,
|
||||
type StreamEvent,
|
||||
type BaileysEventType,
|
||||
type EventPriority,
|
||||
} from '../../Utils/baileys-event-stream.js'
|
||||
|
||||
describe('BaileysEventStream', () => {
|
||||
let stream: BaileysEventStream
|
||||
|
||||
beforeEach(() => {
|
||||
stream = createEventStream({
|
||||
maxBufferSize: 100,
|
||||
batchSize: 10,
|
||||
collectMetrics: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stream.destroy()
|
||||
})
|
||||
|
||||
describe('push events', () => {
|
||||
it('should push event to stream', () => {
|
||||
const result = stream.push('messages.upsert', { message: 'test' })
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(stream.getStats().bufferSize).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should assign priority based on event type', (done) => {
|
||||
stream.on('*', (event) => {
|
||||
expect(event.priority).toBe('critical')
|
||||
done()
|
||||
})
|
||||
|
||||
stream.push('connection.update', { state: 'open' })
|
||||
})
|
||||
|
||||
it('should use custom priority when provided', (done) => {
|
||||
stream.on('*', (event) => {
|
||||
expect(event.priority).toBe('low')
|
||||
done()
|
||||
})
|
||||
|
||||
stream.push('messages.upsert', { message: 'test' }, { priority: 'low' })
|
||||
})
|
||||
|
||||
it('should assign correct category', (done) => {
|
||||
stream.on('*', (event) => {
|
||||
expect(event.category).toBe('message')
|
||||
done()
|
||||
})
|
||||
|
||||
stream.push('messages.upsert', { message: 'test' })
|
||||
})
|
||||
|
||||
it('should reject events when buffer is full', () => {
|
||||
const smallStream = createEventStream({
|
||||
maxBufferSize: 5,
|
||||
enableBackpressure: true,
|
||||
highWaterMark: 3,
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
smallStream.pause() // Prevent processing
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
smallStream.push('messages.upsert', { index: i })
|
||||
}
|
||||
|
||||
const result = smallStream.push('messages.upsert', { overflow: true })
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(smallStream.getStats().totalDropped).toBe(1)
|
||||
|
||||
smallStream.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event handlers', () => {
|
||||
it('should call handler for specific event type', async () => {
|
||||
const handler = jest.fn()
|
||||
|
||||
stream.on('messages.upsert', handler)
|
||||
stream.push('messages.upsert', { message: 'test' })
|
||||
|
||||
// Wait for processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call global handler for all events', async () => {
|
||||
const handler = jest.fn()
|
||||
|
||||
stream.on('*', handler)
|
||||
stream.push('messages.upsert', { message: 'test' })
|
||||
stream.push('connection.update', { state: 'open' })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should support once handler', async () => {
|
||||
const handler = jest.fn()
|
||||
|
||||
stream.once('messages.upsert', handler)
|
||||
stream.push('messages.upsert', { first: true })
|
||||
stream.push('messages.upsert', { second: true })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remove handler with off', async () => {
|
||||
const handler = jest.fn()
|
||||
|
||||
stream.on('messages.upsert', handler)
|
||||
stream.off('messages.upsert', handler)
|
||||
stream.push('messages.upsert', { message: 'test' })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pause and resume', () => {
|
||||
it('should pause processing', async () => {
|
||||
const handler = jest.fn()
|
||||
|
||||
stream.on('messages.upsert', handler)
|
||||
stream.pause()
|
||||
|
||||
expect(stream.isPaused()).toBe(true)
|
||||
|
||||
stream.push('messages.upsert', { message: 'test' })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should resume processing', async () => {
|
||||
const handler = jest.fn()
|
||||
|
||||
stream.on('messages.upsert', handler)
|
||||
stream.pause()
|
||||
stream.push('messages.upsert', { message: 'test' })
|
||||
stream.resume()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('flush', () => {
|
||||
it('should process all buffered events', async () => {
|
||||
const handler = jest.fn()
|
||||
|
||||
stream.pause()
|
||||
stream.on('messages.upsert', handler)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
stream.push('messages.upsert', { index: i })
|
||||
}
|
||||
|
||||
stream.resume()
|
||||
const result = await stream.flush()
|
||||
|
||||
expect(result.processed).toBe(5)
|
||||
expect(handler).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filters', () => {
|
||||
it('should filter events before processing', async () => {
|
||||
const handler = jest.fn()
|
||||
|
||||
stream.addFilter((event) => event.data.include === true)
|
||||
stream.on('*', handler)
|
||||
|
||||
stream.push('messages.upsert', { include: true })
|
||||
stream.push('messages.upsert', { include: false })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remove filter', async () => {
|
||||
const filter = (event: StreamEvent) => false
|
||||
|
||||
stream.addFilter(filter)
|
||||
stream.removeFilter(filter)
|
||||
|
||||
const handler = jest.fn()
|
||||
stream.on('*', handler)
|
||||
|
||||
stream.push('messages.upsert', { test: true })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(handler).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformers', () => {
|
||||
it('should transform events before processing', async () => {
|
||||
stream.addTransformer((event) => ({
|
||||
...event,
|
||||
metadata: { ...event.metadata, transformed: true },
|
||||
}))
|
||||
|
||||
let receivedEvent: StreamEvent | null = null
|
||||
stream.on('*', (event) => {
|
||||
receivedEvent = event
|
||||
})
|
||||
|
||||
stream.push('messages.upsert', { message: 'test' })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
expect(receivedEvent?.metadata?.transformed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dead letter queue', () => {
|
||||
it('should move failed events to DLQ after max retries', async () => {
|
||||
const failingHandler = jest.fn(() => {
|
||||
throw new Error('Processing failed')
|
||||
})
|
||||
|
||||
const dlqStream = createEventStream({
|
||||
maxRetries: 2,
|
||||
batchSize: 1,
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
dlqStream.on('messages.upsert', failingHandler)
|
||||
dlqStream.push('messages.upsert', { will: 'fail' })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
|
||||
const dlq = dlqStream.getDeadLetterQueue()
|
||||
expect(dlq.length).toBeGreaterThan(0)
|
||||
|
||||
dlqStream.destroy()
|
||||
})
|
||||
|
||||
it('should clear DLQ', () => {
|
||||
stream.push('test', { data: 'test' })
|
||||
|
||||
// Manually add to DLQ for testing
|
||||
const dlq = stream.getDeadLetterQueue()
|
||||
|
||||
stream.clearDeadLetterQueue()
|
||||
|
||||
expect(stream.getStats().deadLetterQueueSize).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('statistics', () => {
|
||||
it('should track event statistics', async () => {
|
||||
stream.on('*', () => {})
|
||||
|
||||
stream.push('messages.upsert', { a: 1 })
|
||||
stream.push('connection.update', { b: 2 })
|
||||
stream.push('messages.update', { c: 3 })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const stats = stream.getStats()
|
||||
|
||||
expect(stats.totalReceived).toBe(3)
|
||||
expect(stats.totalProcessed).toBe(3)
|
||||
expect(stats.eventsByType['messages.upsert']).toBe(1)
|
||||
expect(stats.eventsByType['connection.update']).toBe(1)
|
||||
})
|
||||
|
||||
it('should reset statistics', async () => {
|
||||
stream.on('*', () => {})
|
||||
stream.push('messages.upsert', { test: true })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
stream.resetStats()
|
||||
|
||||
const stats = stream.getStats()
|
||||
expect(stats.totalReceived).toBe(0)
|
||||
expect(stats.totalProcessed).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('priority ordering', () => {
|
||||
it('should process critical events before normal events', async () => {
|
||||
const processedOrder: EventPriority[] = []
|
||||
|
||||
stream.pause()
|
||||
|
||||
stream.on('*', (event) => {
|
||||
processedOrder.push(event.priority)
|
||||
})
|
||||
|
||||
// Add in reverse priority order
|
||||
stream.push('presence.update', {}, { priority: 'low' })
|
||||
stream.push('messages.upsert', {}, { priority: 'normal' })
|
||||
stream.push('connection.update', {}, { priority: 'critical' })
|
||||
stream.push('call', {}, { priority: 'high' })
|
||||
|
||||
stream.resume()
|
||||
await stream.flush()
|
||||
|
||||
expect(processedOrder[0]).toBe('critical')
|
||||
expect(processedOrder[1]).toBe('high')
|
||||
expect(processedOrder[2]).toBe('normal')
|
||||
expect(processedOrder[3]).toBe('low')
|
||||
})
|
||||
})
|
||||
|
||||
describe('backpressure', () => {
|
||||
it('should emit backpressure event when high water mark reached', (done) => {
|
||||
const bpStream = createEventStream({
|
||||
maxBufferSize: 100,
|
||||
highWaterMark: 5,
|
||||
enableBackpressure: true,
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
bpStream.pause()
|
||||
|
||||
bpStream.on('backpressure', () => {
|
||||
expect(bpStream.getStats().isBackpressured).toBe(true)
|
||||
bpStream.destroy()
|
||||
done()
|
||||
})
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
bpStream.push('messages.upsert', { index: i })
|
||||
}
|
||||
})
|
||||
|
||||
it('should emit drain event when below low water mark', (done) => {
|
||||
const bpStream = createEventStream({
|
||||
maxBufferSize: 100,
|
||||
highWaterMark: 5,
|
||||
lowWaterMark: 2,
|
||||
enableBackpressure: true,
|
||||
batchSize: 10,
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
bpStream.pause()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
bpStream.push('messages.upsert', { index: i })
|
||||
}
|
||||
|
||||
bpStream.on('*', () => {})
|
||||
bpStream.on('drain', () => {
|
||||
bpStream.destroy()
|
||||
done()
|
||||
})
|
||||
|
||||
bpStream.resume()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear buffer', () => {
|
||||
stream.pause()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stream.push('messages.upsert', { index: i })
|
||||
}
|
||||
|
||||
stream.clear()
|
||||
|
||||
expect(stream.getStats().bufferSize).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventFilters', () => {
|
||||
describe('byType', () => {
|
||||
it('should filter by event type', () => {
|
||||
const filter = eventFilters.byType('messages.upsert', 'messages.update')
|
||||
|
||||
const matchingEvent: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
const nonMatchingEvent: StreamEvent = {
|
||||
id: '2',
|
||||
type: 'connection.update',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'connection',
|
||||
}
|
||||
|
||||
expect(filter(matchingEvent)).toBe(true)
|
||||
expect(filter(nonMatchingEvent)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('byCategory', () => {
|
||||
it('should filter by category', () => {
|
||||
const filter = eventFilters.byCategory('message', 'connection')
|
||||
|
||||
const matchingEvent: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
const nonMatchingEvent: StreamEvent = {
|
||||
id: '2',
|
||||
type: 'presence.update',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'presence',
|
||||
}
|
||||
|
||||
expect(filter(matchingEvent)).toBe(true)
|
||||
expect(filter(nonMatchingEvent)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('byMinPriority', () => {
|
||||
it('should filter by minimum priority', () => {
|
||||
const filter = eventFilters.byMinPriority('high')
|
||||
|
||||
const criticalEvent: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'connection.update',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'critical',
|
||||
category: 'connection',
|
||||
}
|
||||
|
||||
const lowEvent: StreamEvent = {
|
||||
id: '2',
|
||||
type: 'presence.update',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'low',
|
||||
category: 'presence',
|
||||
}
|
||||
|
||||
expect(filter(criticalEvent)).toBe(true)
|
||||
expect(filter(lowEvent)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recentOnly', () => {
|
||||
it('should filter old events', () => {
|
||||
const filter = eventFilters.recentOnly(1000)
|
||||
|
||||
const recentEvent: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
const oldEvent: StreamEvent = {
|
||||
id: '2',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now() - 5000,
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
expect(filter(recentEvent)).toBe(true)
|
||||
expect(filter(oldEvent)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('and', () => {
|
||||
it('should combine filters with AND', () => {
|
||||
const filter = eventFilters.and(
|
||||
eventFilters.byType('messages.upsert'),
|
||||
eventFilters.byMinPriority('high')
|
||||
)
|
||||
|
||||
const matchingEvent: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'high',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
const partialMatch: StreamEvent = {
|
||||
id: '2',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'low',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
expect(filter(matchingEvent)).toBe(true)
|
||||
expect(filter(partialMatch)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('or', () => {
|
||||
it('should combine filters with OR', () => {
|
||||
const filter = eventFilters.or(
|
||||
eventFilters.byType('messages.upsert'),
|
||||
eventFilters.byCategory('connection')
|
||||
)
|
||||
|
||||
const typeMatch: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
const categoryMatch: StreamEvent = {
|
||||
id: '2',
|
||||
type: 'connection.update',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'connection',
|
||||
}
|
||||
|
||||
const noMatch: StreamEvent = {
|
||||
id: '3',
|
||||
type: 'presence.update',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'presence',
|
||||
}
|
||||
|
||||
expect(filter(typeMatch)).toBe(true)
|
||||
expect(filter(categoryMatch)).toBe(true)
|
||||
expect(filter(noMatch)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('eventTransformers', () => {
|
||||
describe('addProcessingTimestamp', () => {
|
||||
it('should add processing timestamp', () => {
|
||||
const transformer = eventTransformers.addProcessingTimestamp()
|
||||
|
||||
const event: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now() - 1000,
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
const transformed = transformer(event)
|
||||
|
||||
expect(transformed.metadata?.processingTimestamp).toBeDefined()
|
||||
expect(transformed.metadata?.processingTimestamp).toBeGreaterThan(event.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addTraceId', () => {
|
||||
it('should add trace ID', () => {
|
||||
const transformer = eventTransformers.addTraceId(() => 'trace-123')
|
||||
|
||||
const event: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'messages.upsert',
|
||||
data: {},
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
const transformed = transformer(event)
|
||||
|
||||
expect(transformed.metadata?.traceId).toBe('trace-123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('elevatepriorityIf', () => {
|
||||
it('should elevate priority when condition is met', () => {
|
||||
const transformer = eventTransformers.elevatepriorityIf(
|
||||
(event) => (event.data as { urgent?: boolean }).urgent === true,
|
||||
'critical'
|
||||
)
|
||||
|
||||
const urgentEvent: StreamEvent = {
|
||||
id: '1',
|
||||
type: 'messages.upsert',
|
||||
data: { urgent: true },
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
const normalEvent: StreamEvent = {
|
||||
id: '2',
|
||||
type: 'messages.upsert',
|
||||
data: { urgent: false },
|
||||
timestamp: Date.now(),
|
||||
priority: 'normal',
|
||||
category: 'message',
|
||||
}
|
||||
|
||||
expect(transformer(urgentEvent).priority).toBe('critical')
|
||||
expect(transformer(normalEvent).priority).toBe('normal')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Testes unitários para cache-utils.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
|
||||
import {
|
||||
Cache,
|
||||
createCache,
|
||||
MultiLevelCache,
|
||||
withCache,
|
||||
getGlobalCache,
|
||||
clearGlobalCaches,
|
||||
} from '../../Utils/cache-utils.js'
|
||||
|
||||
describe('Cache', () => {
|
||||
let cache: Cache<string>
|
||||
|
||||
beforeEach(() => {
|
||||
cache = createCache<string>({
|
||||
ttl: 1000,
|
||||
maxSize: 100,
|
||||
collectMetrics: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('basic operations', () => {
|
||||
it('should set and get values', () => {
|
||||
cache.set('key1', 'value1')
|
||||
expect(cache.get('key1')).toBe('value1')
|
||||
})
|
||||
|
||||
it('should return undefined for non-existent keys', () => {
|
||||
expect(cache.get('nonexistent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should check if key exists', () => {
|
||||
cache.set('exists', 'value')
|
||||
expect(cache.has('exists')).toBe(true)
|
||||
expect(cache.has('notexists')).toBe(false)
|
||||
})
|
||||
|
||||
it('should delete keys', () => {
|
||||
cache.set('toDelete', 'value')
|
||||
expect(cache.delete('toDelete')).toBe(true)
|
||||
expect(cache.get('toDelete')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear all values', () => {
|
||||
cache.set('key1', 'value1')
|
||||
cache.set('key2', 'value2')
|
||||
cache.clear()
|
||||
expect(cache.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TTL', () => {
|
||||
it('should expire values after TTL', async () => {
|
||||
const shortTtlCache = createCache<string>({
|
||||
ttl: 50,
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
shortTtlCache.set('expiring', 'value')
|
||||
expect(shortTtlCache.get('expiring')).toBe('value')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(shortTtlCache.get('expiring')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should support custom TTL per item', async () => {
|
||||
cache.set('shortLived', 'value', 50)
|
||||
cache.set('longLived', 'value', 5000)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(cache.get('shortLived')).toBeUndefined()
|
||||
expect(cache.get('longLived')).toBe('value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrSet', () => {
|
||||
it('should return cached value if exists', async () => {
|
||||
cache.set('cached', 'existingValue')
|
||||
|
||||
const factory = jest.fn(() => 'newValue')
|
||||
const result = await cache.getOrSet('cached', factory)
|
||||
|
||||
expect(result).toBe('existingValue')
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call factory and cache if not exists', async () => {
|
||||
const factory = jest.fn(() => 'newValue')
|
||||
const result = await cache.getOrSet('new', factory)
|
||||
|
||||
expect(result).toBe('newValue')
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
expect(cache.get('new')).toBe('newValue')
|
||||
})
|
||||
|
||||
it('should handle async factories', async () => {
|
||||
const factory = jest.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
return 'asyncValue'
|
||||
})
|
||||
|
||||
const result = await cache.getOrSet('async', factory)
|
||||
|
||||
expect(result).toBe('asyncValue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrSetSync', () => {
|
||||
it('should work synchronously', () => {
|
||||
const factory = jest.fn(() => 'syncValue')
|
||||
const result = cache.getOrSetSync('sync', factory)
|
||||
|
||||
expect(result).toBe('syncValue')
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidation', () => {
|
||||
it('should invalidate by pattern', () => {
|
||||
cache.set('user:1', 'user1')
|
||||
cache.set('user:2', 'user2')
|
||||
cache.set('post:1', 'post1')
|
||||
|
||||
const count = cache.invalidateByPattern(/^user:/)
|
||||
|
||||
expect(count).toBe(2)
|
||||
expect(cache.get('user:1')).toBeUndefined()
|
||||
expect(cache.get('user:2')).toBeUndefined()
|
||||
expect(cache.get('post:1')).toBe('post1')
|
||||
})
|
||||
|
||||
it('should invalidate by prefix', () => {
|
||||
cache.set('prefix:a', 'a')
|
||||
cache.set('prefix:b', 'b')
|
||||
cache.set('other:c', 'c')
|
||||
|
||||
const count = cache.invalidateByPrefix('prefix:')
|
||||
|
||||
expect(count).toBe(2)
|
||||
expect(cache.get('prefix:a')).toBeUndefined()
|
||||
expect(cache.get('other:c')).toBe('c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('statistics', () => {
|
||||
it('should track hits and misses', () => {
|
||||
cache.set('key', 'value')
|
||||
|
||||
cache.get('key') // hit
|
||||
cache.get('key') // hit
|
||||
cache.get('nonexistent') // miss
|
||||
|
||||
const stats = cache.getStats()
|
||||
|
||||
expect(stats.hits).toBe(2)
|
||||
expect(stats.misses).toBe(1)
|
||||
expect(stats.hitRate).toBeCloseTo(2 / 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('touch', () => {
|
||||
it('should update TTL of existing item', () => {
|
||||
cache.set('touchable', 'value')
|
||||
expect(cache.touch('touchable')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-existent item', () => {
|
||||
expect(cache.touch('nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWithResult', () => {
|
||||
it('should return detailed result on hit', () => {
|
||||
cache.set('key', 'value')
|
||||
const result = cache.getWithResult('key')
|
||||
|
||||
expect(result.hit).toBe(true)
|
||||
expect(result.value).toBe('value')
|
||||
expect(result.key).toBe('key')
|
||||
})
|
||||
|
||||
it('should return detailed result on miss', () => {
|
||||
const result = cache.getWithResult('missing')
|
||||
|
||||
expect(result.hit).toBe(false)
|
||||
expect(result.value).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MultiLevelCache', () => {
|
||||
it('should try L1 first', async () => {
|
||||
const l2Get = jest.fn()
|
||||
const l2Set = jest.fn()
|
||||
const l2Delete = jest.fn()
|
||||
|
||||
const multiCache = new MultiLevelCache<string>(
|
||||
{ ttl: 1000, collectMetrics: false },
|
||||
{ get: l2Get, set: l2Set, delete: l2Delete }
|
||||
)
|
||||
|
||||
multiCache.getL1().set('local', 'value')
|
||||
|
||||
const result = await multiCache.get('local')
|
||||
|
||||
expect(result).toBe('value')
|
||||
expect(l2Get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fallback to L2 on L1 miss', async () => {
|
||||
const l2Get = jest.fn(async () => 'l2value')
|
||||
const l2Set = jest.fn()
|
||||
const l2Delete = jest.fn()
|
||||
|
||||
const multiCache = new MultiLevelCache<string>(
|
||||
{ ttl: 1000, collectMetrics: false },
|
||||
{ get: l2Get, set: l2Set, delete: l2Delete }
|
||||
)
|
||||
|
||||
const result = await multiCache.get('fromL2')
|
||||
|
||||
expect(result).toBe('l2value')
|
||||
expect(l2Get).toHaveBeenCalledWith('fromL2')
|
||||
})
|
||||
|
||||
it('should write to both levels', async () => {
|
||||
const l2Set = jest.fn()
|
||||
|
||||
const multiCache = new MultiLevelCache<string>(
|
||||
{ ttl: 1000, collectMetrics: false },
|
||||
{ get: jest.fn(), set: l2Set, delete: jest.fn() }
|
||||
)
|
||||
|
||||
await multiCache.set('key', 'value')
|
||||
|
||||
expect(multiCache.getL1().get('key')).toBe('value')
|
||||
expect(l2Set).toHaveBeenCalledWith('key', 'value', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withCache', () => {
|
||||
it('should cache function results', async () => {
|
||||
let callCount = 0
|
||||
const expensiveFn = (x: number) => {
|
||||
callCount++
|
||||
return x * 2
|
||||
}
|
||||
|
||||
const cachedFn = withCache(expensiveFn, { ttl: 1000, collectMetrics: false })
|
||||
|
||||
const result1 = await cachedFn(5)
|
||||
const result2 = await cachedFn(5)
|
||||
const result3 = await cachedFn(10)
|
||||
|
||||
expect(result1).toBe(10)
|
||||
expect(result2).toBe(10)
|
||||
expect(result3).toBe(20)
|
||||
expect(callCount).toBe(2) // 5 was cached, 10 was not
|
||||
})
|
||||
})
|
||||
|
||||
describe('globalCache', () => {
|
||||
beforeEach(() => {
|
||||
clearGlobalCaches()
|
||||
})
|
||||
|
||||
it('should create and retrieve global cache by namespace', () => {
|
||||
const cache1 = getGlobalCache('ns1')
|
||||
const cache2 = getGlobalCache('ns1')
|
||||
const cache3 = getGlobalCache('ns2')
|
||||
|
||||
expect(cache1).toBe(cache2)
|
||||
expect(cache1).not.toBe(cache3)
|
||||
})
|
||||
|
||||
it('should clear all global caches', () => {
|
||||
const cache1 = getGlobalCache<string>('test1')
|
||||
const cache2 = getGlobalCache<string>('test2')
|
||||
|
||||
cache1.set('key', 'value')
|
||||
cache2.set('key', 'value')
|
||||
|
||||
clearGlobalCaches()
|
||||
|
||||
// After clear, new caches should be created
|
||||
const newCache1 = getGlobalCache<string>('test1')
|
||||
expect(newCache1.get('key')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Testes unitários para circuit-breaker.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
|
||||
import {
|
||||
CircuitBreaker,
|
||||
createCircuitBreaker,
|
||||
CircuitBreakerRegistry,
|
||||
globalCircuitRegistry,
|
||||
CircuitOpenError,
|
||||
CircuitTimeoutError,
|
||||
withCircuitBreaker,
|
||||
getCircuitHealth,
|
||||
type CircuitState,
|
||||
} from '../../Utils/circuit-breaker.js'
|
||||
|
||||
describe('CircuitBreaker', () => {
|
||||
let breaker: CircuitBreaker
|
||||
|
||||
beforeEach(() => {
|
||||
breaker = createCircuitBreaker({
|
||||
name: 'test',
|
||||
failureThreshold: 3,
|
||||
successThreshold: 2,
|
||||
resetTimeout: 100,
|
||||
timeout: 1000,
|
||||
collectMetrics: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
breaker.destroy()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should start in closed state', () => {
|
||||
expect(breaker.getState()).toBe('closed')
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
expect(breaker.isOpen()).toBe(false)
|
||||
expect(breaker.isHalfOpen()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('successful operations', () => {
|
||||
it('should execute successful operations', async () => {
|
||||
const result = await breaker.execute(() => 'success')
|
||||
expect(result).toBe('success')
|
||||
})
|
||||
|
||||
it('should execute async operations', async () => {
|
||||
const result = await breaker.execute(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
return 'async success'
|
||||
})
|
||||
expect(result).toBe('async success')
|
||||
})
|
||||
|
||||
it('should track successful calls in stats', async () => {
|
||||
await breaker.execute(() => 'success')
|
||||
await breaker.execute(() => 'success')
|
||||
|
||||
const stats = breaker.getStats()
|
||||
expect(stats.totalCalls).toBe(2)
|
||||
expect(stats.totalSuccesses).toBe(2)
|
||||
expect(stats.totalFailures).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('failure handling', () => {
|
||||
it('should open after reaching failure threshold', async () => {
|
||||
const failingOp = () => {
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
// Reach failure threshold
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow('Failure')
|
||||
}
|
||||
|
||||
expect(breaker.isOpen()).toBe(true)
|
||||
})
|
||||
|
||||
it('should not open before reaching threshold', async () => {
|
||||
const failingOp = () => {
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
// Below threshold
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow('Failure')
|
||||
}
|
||||
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reset failure count on success', async () => {
|
||||
const failingOp = () => {
|
||||
throw new Error('Failure')
|
||||
}
|
||||
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow()
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow()
|
||||
|
||||
// Success resets failures
|
||||
await breaker.execute(() => 'success')
|
||||
|
||||
// Need 3 more failures to open
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow()
|
||||
await expect(breaker.execute(failingOp)).rejects.toThrow()
|
||||
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('open state', () => {
|
||||
beforeEach(async () => {
|
||||
// Force open
|
||||
breaker.trip()
|
||||
})
|
||||
|
||||
it('should reject operations when open', async () => {
|
||||
await expect(breaker.execute(() => 'success')).rejects.toThrow(CircuitOpenError)
|
||||
})
|
||||
|
||||
it('should transition to half-open after reset timeout', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
expect(breaker.isHalfOpen()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('half-open state', () => {
|
||||
beforeEach(async () => {
|
||||
breaker.trip()
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
})
|
||||
|
||||
it('should close after success threshold', async () => {
|
||||
expect(breaker.isHalfOpen()).toBe(true)
|
||||
|
||||
// Success threshold is 2
|
||||
await breaker.execute(() => 'success')
|
||||
await breaker.execute(() => 'success')
|
||||
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
|
||||
it('should reopen on failure', async () => {
|
||||
expect(breaker.isHalfOpen()).toBe(true)
|
||||
|
||||
await expect(
|
||||
breaker.execute(() => {
|
||||
throw new Error('Failure')
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(breaker.isOpen()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('manual controls', () => {
|
||||
it('should force open with trip()', () => {
|
||||
breaker.trip()
|
||||
expect(breaker.isOpen()).toBe(true)
|
||||
})
|
||||
|
||||
it('should force close with reset()', async () => {
|
||||
breaker.trip()
|
||||
breaker.reset()
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeout', () => {
|
||||
it('should timeout slow operations', async () => {
|
||||
const slowBreaker = createCircuitBreaker({
|
||||
name: 'slow',
|
||||
timeout: 50,
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
await expect(
|
||||
slowBreaker.execute(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 200))
|
||||
)
|
||||
).rejects.toThrow(CircuitTimeoutError)
|
||||
|
||||
slowBreaker.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('should emit state-change event', async () => {
|
||||
const stateChanges: Array<{ from: CircuitState; to: CircuitState }> = []
|
||||
|
||||
breaker.on('state-change', (change) => {
|
||||
stateChanges.push(change)
|
||||
})
|
||||
|
||||
breaker.trip()
|
||||
breaker.reset()
|
||||
|
||||
expect(stateChanges).toHaveLength(2)
|
||||
expect(stateChanges[0]).toEqual({ from: 'closed', to: 'open' })
|
||||
expect(stateChanges[1]).toEqual({ from: 'open', to: 'closed' })
|
||||
})
|
||||
|
||||
it('should emit success and failure events', async () => {
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
|
||||
breaker.on('success', () => successCount++)
|
||||
breaker.on('failure', () => failureCount++)
|
||||
|
||||
await breaker.execute(() => 'success')
|
||||
await expect(
|
||||
breaker.execute(() => {
|
||||
throw new Error()
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(successCount).toBe(1)
|
||||
expect(failureCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFailure predicate', () => {
|
||||
it('should use custom isFailure predicate', async () => {
|
||||
const customBreaker = createCircuitBreaker({
|
||||
name: 'custom',
|
||||
failureThreshold: 1,
|
||||
isFailure: (error) => error.message !== 'Ignored',
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
// This error should be ignored
|
||||
await expect(
|
||||
customBreaker.execute(() => {
|
||||
throw new Error('Ignored')
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(customBreaker.isClosed()).toBe(true)
|
||||
|
||||
// This should trip the breaker
|
||||
await expect(
|
||||
customBreaker.execute(() => {
|
||||
throw new Error('Real failure')
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(customBreaker.isOpen()).toBe(true)
|
||||
|
||||
customBreaker.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sync operations', () => {
|
||||
it('should execute sync operations', () => {
|
||||
const result = breaker.executeSync(() => 'sync result')
|
||||
expect(result).toBe('sync result')
|
||||
})
|
||||
|
||||
it('should handle sync failures', () => {
|
||||
expect(() =>
|
||||
breaker.executeSync(() => {
|
||||
throw new Error('Sync failure')
|
||||
})
|
||||
).toThrow('Sync failure')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CircuitBreakerRegistry', () => {
|
||||
let registry: CircuitBreakerRegistry
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new CircuitBreakerRegistry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
registry.destroyAll()
|
||||
})
|
||||
|
||||
it('should create and retrieve circuit breakers', () => {
|
||||
const breaker1 = registry.get('test1')
|
||||
const breaker2 = registry.get('test1')
|
||||
|
||||
expect(breaker1).toBe(breaker2)
|
||||
})
|
||||
|
||||
it('should check if breaker exists', () => {
|
||||
registry.get('exists')
|
||||
|
||||
expect(registry.has('exists')).toBe(true)
|
||||
expect(registry.has('notexists')).toBe(false)
|
||||
})
|
||||
|
||||
it('should remove breaker', () => {
|
||||
registry.get('toRemove')
|
||||
expect(registry.remove('toRemove')).toBe(true)
|
||||
expect(registry.has('toRemove')).toBe(false)
|
||||
})
|
||||
|
||||
it('should get all stats', () => {
|
||||
registry.get('breaker1')
|
||||
registry.get('breaker2')
|
||||
|
||||
const stats = registry.getAllStats()
|
||||
|
||||
expect(stats).toHaveProperty('breaker1')
|
||||
expect(stats).toHaveProperty('breaker2')
|
||||
})
|
||||
|
||||
it('should reset all breakers', async () => {
|
||||
const breaker = registry.get('test')
|
||||
breaker.trip()
|
||||
|
||||
registry.resetAll()
|
||||
|
||||
expect(breaker.isClosed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withCircuitBreaker', () => {
|
||||
it('should wrap function with circuit breaker', async () => {
|
||||
let callCount = 0
|
||||
|
||||
const protectedFn = withCircuitBreaker(
|
||||
async () => {
|
||||
callCount++
|
||||
return 'result'
|
||||
},
|
||||
{
|
||||
name: 'wrapped-fn',
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
|
||||
const result = await protectedFn()
|
||||
|
||||
expect(result).toBe('result')
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCircuitHealth', () => {
|
||||
beforeEach(() => {
|
||||
globalCircuitRegistry.destroyAll()
|
||||
})
|
||||
|
||||
it('should report healthy when all circuits closed', () => {
|
||||
globalCircuitRegistry.get('healthy1')
|
||||
globalCircuitRegistry.get('healthy2')
|
||||
|
||||
const health = getCircuitHealth()
|
||||
|
||||
expect(health.healthy).toBe(true)
|
||||
expect(health.openCircuits).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should report unhealthy when circuit is open', () => {
|
||||
const breaker = globalCircuitRegistry.get('unhealthy')
|
||||
breaker.trip()
|
||||
|
||||
const health = getCircuitHealth()
|
||||
|
||||
expect(health.healthy).toBe(false)
|
||||
expect(health.openCircuits).toContain('unhealthy')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Testes unitários para retry-utils.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
|
||||
import {
|
||||
retry,
|
||||
retryWithResult,
|
||||
createRetrier,
|
||||
retryable,
|
||||
RetryManager,
|
||||
RetryExhaustedError,
|
||||
RetryAbortedError,
|
||||
calculateDelay,
|
||||
retryPredicates,
|
||||
retryConfigs,
|
||||
type RetryContext,
|
||||
} from '../../Utils/retry-utils.js'
|
||||
|
||||
describe('retry', () => {
|
||||
describe('successful operations', () => {
|
||||
it('should return result on first success', async () => {
|
||||
const result = await retry(() => 'success', { collectMetrics: false })
|
||||
expect(result).toBe('success')
|
||||
})
|
||||
|
||||
it('should return result from async operation', async () => {
|
||||
const result = await retry(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
return 'async success'
|
||||
}, { collectMetrics: false })
|
||||
|
||||
expect(result).toBe('async success')
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry behavior', () => {
|
||||
it('should retry on failure', async () => {
|
||||
let attempts = 0
|
||||
|
||||
const result = await retry(
|
||||
() => {
|
||||
attempts++
|
||||
if (attempts < 3) {
|
||||
throw new Error('Failing')
|
||||
}
|
||||
return 'success after retries'
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10,
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBe('success after retries')
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('should exhaust retries and throw', async () => {
|
||||
await expect(
|
||||
retry(
|
||||
() => {
|
||||
throw new Error('Always fails')
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
baseDelay: 10,
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(RetryExhaustedError)
|
||||
})
|
||||
|
||||
it('should pass context to operation', async () => {
|
||||
let receivedContext: RetryContext | null = null
|
||||
|
||||
await retry(
|
||||
(context) => {
|
||||
receivedContext = context
|
||||
return 'success'
|
||||
},
|
||||
{ collectMetrics: false }
|
||||
)
|
||||
|
||||
expect(receivedContext).not.toBeNull()
|
||||
expect(receivedContext!.attempt).toBe(1)
|
||||
expect(receivedContext!.maxAttempts).toBe(3) // default
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRetry predicate', () => {
|
||||
it('should use custom shouldRetry', async () => {
|
||||
let attempts = 0
|
||||
|
||||
await expect(
|
||||
retry(
|
||||
() => {
|
||||
attempts++
|
||||
throw new Error('Non-retryable')
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10,
|
||||
shouldRetry: () => false,
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow(RetryExhaustedError)
|
||||
|
||||
expect(attempts).toBe(1) // Should not retry
|
||||
})
|
||||
})
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('should call onRetry callback', async () => {
|
||||
const onRetry = jest.fn()
|
||||
let attempts = 0
|
||||
|
||||
await retry(
|
||||
() => {
|
||||
attempts++
|
||||
if (attempts < 3) {
|
||||
throw new Error('Failing')
|
||||
}
|
||||
return 'success'
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10,
|
||||
onRetry,
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
|
||||
expect(onRetry).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should call onSuccess callback', async () => {
|
||||
const onSuccess = jest.fn()
|
||||
|
||||
await retry(() => 'result', {
|
||||
onSuccess,
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('result', 1)
|
||||
})
|
||||
|
||||
it('should call onFailure callback on exhaustion', async () => {
|
||||
const onFailure = jest.fn()
|
||||
|
||||
await expect(
|
||||
retry(
|
||||
() => {
|
||||
throw new Error('Always fails')
|
||||
},
|
||||
{
|
||||
maxAttempts: 2,
|
||||
baseDelay: 10,
|
||||
onFailure,
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
).rejects.toThrow()
|
||||
|
||||
expect(onFailure).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('abort signal', () => {
|
||||
it('should abort with signal', async () => {
|
||||
const controller = new AbortController()
|
||||
|
||||
const promise = retry(
|
||||
async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
return 'success'
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 50,
|
||||
abortSignal: controller.signal,
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
|
||||
setTimeout(() => controller.abort(), 20)
|
||||
|
||||
await expect(promise).rejects.toThrow(RetryAbortedError)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryWithResult', () => {
|
||||
it('should return success result', async () => {
|
||||
const result = await retryWithResult(() => 'success', { collectMetrics: false })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.result).toBe('success')
|
||||
expect(result.attempts).toBe(1)
|
||||
expect(result.totalDuration).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('should return failure result', async () => {
|
||||
const result = await retryWithResult(
|
||||
() => {
|
||||
throw new Error('Failure')
|
||||
},
|
||||
{
|
||||
maxAttempts: 2,
|
||||
baseDelay: 10,
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeInstanceOf(RetryExhaustedError)
|
||||
expect(result.attempts).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateDelay', () => {
|
||||
describe('exponential backoff', () => {
|
||||
it('should calculate exponential delays', () => {
|
||||
const delay1 = calculateDelay(1, 100, 10000, 'exponential', 2, 0)
|
||||
const delay2 = calculateDelay(2, 100, 10000, 'exponential', 2, 0)
|
||||
const delay3 = calculateDelay(3, 100, 10000, 'exponential', 2, 0)
|
||||
|
||||
expect(delay1).toBe(100)
|
||||
expect(delay2).toBe(200)
|
||||
expect(delay3).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linear backoff', () => {
|
||||
it('should calculate linear delays', () => {
|
||||
const delay1 = calculateDelay(1, 100, 10000, 'linear', 2, 0)
|
||||
const delay2 = calculateDelay(2, 100, 10000, 'linear', 2, 0)
|
||||
const delay3 = calculateDelay(3, 100, 10000, 'linear', 2, 0)
|
||||
|
||||
expect(delay1).toBe(100)
|
||||
expect(delay2).toBe(200)
|
||||
expect(delay3).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('constant backoff', () => {
|
||||
it('should return constant delays', () => {
|
||||
const delay1 = calculateDelay(1, 100, 10000, 'constant', 2, 0)
|
||||
const delay2 = calculateDelay(2, 100, 10000, 'constant', 2, 0)
|
||||
const delay3 = calculateDelay(3, 100, 10000, 'constant', 2, 0)
|
||||
|
||||
expect(delay1).toBe(100)
|
||||
expect(delay2).toBe(100)
|
||||
expect(delay3).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('max delay cap', () => {
|
||||
it('should cap at max delay', () => {
|
||||
const delay = calculateDelay(10, 100, 500, 'exponential', 2, 0)
|
||||
expect(delay).toBeLessThanOrEqual(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('jitter', () => {
|
||||
it('should add jitter to delay', () => {
|
||||
const delays = new Set()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
delays.add(calculateDelay(1, 100, 10000, 'constant', 2, 0.5))
|
||||
}
|
||||
|
||||
// With jitter, we should get varying delays
|
||||
expect(delays.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createRetrier', () => {
|
||||
it('should create preconfigured retrier', async () => {
|
||||
const myRetrier = createRetrier({
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10,
|
||||
collectMetrics: false,
|
||||
})
|
||||
|
||||
let attempts = 0
|
||||
const result = await myRetrier(() => {
|
||||
attempts++
|
||||
if (attempts < 3) throw new Error('Failing')
|
||||
return 'success'
|
||||
})
|
||||
|
||||
expect(result).toBe('success')
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryable', () => {
|
||||
it('should wrap function with retry', async () => {
|
||||
let attempts = 0
|
||||
|
||||
const fn = retryable(
|
||||
() => {
|
||||
attempts++
|
||||
if (attempts < 2) throw new Error('Failing')
|
||||
return 'wrapped result'
|
||||
},
|
||||
{
|
||||
maxAttempts: 5,
|
||||
baseDelay: 10,
|
||||
collectMetrics: false,
|
||||
}
|
||||
)
|
||||
|
||||
const result = await fn()
|
||||
|
||||
expect(result).toBe('wrapped result')
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RetryManager', () => {
|
||||
let manager: RetryManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new RetryManager({ baseDelay: 10, collectMetrics: false })
|
||||
})
|
||||
|
||||
it('should execute operation with id', async () => {
|
||||
const result = await manager.execute('op1', () => 'result')
|
||||
expect(result).toBe('result')
|
||||
})
|
||||
|
||||
it('should cancel active retry', async () => {
|
||||
const promise = manager.execute('cancelable', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
return 'result'
|
||||
})
|
||||
|
||||
setTimeout(() => manager.cancel('cancelable'), 20)
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should check if operation is active', async () => {
|
||||
let resolve: () => void
|
||||
const promise = manager.execute('active', () =>
|
||||
new Promise<string>((r) => {
|
||||
resolve = () => r('done')
|
||||
})
|
||||
)
|
||||
|
||||
expect(manager.isActive('active')).toBe(true)
|
||||
|
||||
resolve!()
|
||||
await promise
|
||||
|
||||
expect(manager.isActive('active')).toBe(false)
|
||||
})
|
||||
|
||||
it('should cancel all operations', async () => {
|
||||
const promises = [
|
||||
manager.execute('op1', () => new Promise((_, reject) => setTimeout(() => reject(new Error()), 1000))),
|
||||
manager.execute('op2', () => new Promise((_, reject) => setTimeout(() => reject(new Error()), 1000))),
|
||||
]
|
||||
|
||||
setTimeout(() => manager.cancelAll(), 20)
|
||||
|
||||
await expect(Promise.all(promises)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should emit events', async () => {
|
||||
const events: string[] = []
|
||||
|
||||
manager.on('attempt', () => events.push('attempt'))
|
||||
manager.on('success', () => events.push('success'))
|
||||
|
||||
await manager.execute('test', () => 'result')
|
||||
|
||||
expect(events).toContain('attempt')
|
||||
expect(events).toContain('success')
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryPredicates', () => {
|
||||
describe('always', () => {
|
||||
it('should always return true', () => {
|
||||
expect(retryPredicates.always(new Error(), 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('never', () => {
|
||||
it('should always return false', () => {
|
||||
expect(retryPredicates.never(new Error(), 1)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onNetworkError', () => {
|
||||
it('should return true for network errors', () => {
|
||||
expect(retryPredicates.onNetworkError(new Error('ECONNREFUSED'))).toBe(true)
|
||||
expect(retryPredicates.onNetworkError(new Error('ETIMEDOUT'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for other errors', () => {
|
||||
expect(retryPredicates.onNetworkError(new Error('Other error'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onErrorCodes', () => {
|
||||
it('should match specified codes', () => {
|
||||
const predicate = retryPredicates.onErrorCodes(['ECODE1', 'ECODE2'])
|
||||
|
||||
expect(predicate(new Error('ECODE1 occurred'))).toBe(true)
|
||||
expect(predicate(new Error('ECODE3 occurred'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('or', () => {
|
||||
it('should combine predicates with OR', () => {
|
||||
const combined = retryPredicates.or(
|
||||
(e) => e.message.includes('A'),
|
||||
(e) => e.message.includes('B')
|
||||
)
|
||||
|
||||
expect(combined(new Error('A'), 1)).toBe(true)
|
||||
expect(combined(new Error('B'), 1)).toBe(true)
|
||||
expect(combined(new Error('C'), 1)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('and', () => {
|
||||
it('should combine predicates with AND', () => {
|
||||
const combined = retryPredicates.and(
|
||||
(e) => e.message.includes('A'),
|
||||
(e) => e.message.includes('B')
|
||||
)
|
||||
|
||||
expect(combined(new Error('A and B'), 1)).toBe(true)
|
||||
expect(combined(new Error('A only'), 1)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryConfigs', () => {
|
||||
it('should have aggressive config', () => {
|
||||
expect(retryConfigs.aggressive.maxAttempts).toBe(10)
|
||||
expect(retryConfigs.aggressive.baseDelay).toBe(100)
|
||||
})
|
||||
|
||||
it('should have conservative config', () => {
|
||||
expect(retryConfigs.conservative.maxAttempts).toBe(3)
|
||||
expect(retryConfigs.conservative.baseDelay).toBe(2000)
|
||||
})
|
||||
|
||||
it('should have fast config', () => {
|
||||
expect(retryConfigs.fast.maxAttempts).toBe(5)
|
||||
expect(retryConfigs.fast.baseDelay).toBe(50)
|
||||
})
|
||||
|
||||
it('should have network config with predicate', () => {
|
||||
expect(retryConfigs.network.shouldRetry).toBe(retryPredicates.onNetworkError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Testes unitários para structured-logger.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'
|
||||
import {
|
||||
StructuredLogger,
|
||||
createStructuredLogger,
|
||||
getDefaultLogger,
|
||||
setDefaultLogger,
|
||||
createTimer,
|
||||
LOG_LEVEL_VALUES,
|
||||
type LogLevel,
|
||||
} from '../../Utils/structured-logger.js'
|
||||
|
||||
describe('StructuredLogger', () => {
|
||||
let logger: StructuredLogger
|
||||
let consoleSpy: jest.SpiedFunction<typeof console.info>
|
||||
|
||||
beforeEach(() => {
|
||||
logger = createStructuredLogger({
|
||||
level: 'debug',
|
||||
name: 'test',
|
||||
jsonFormat: false,
|
||||
})
|
||||
consoleSpy = jest.spyOn(console, 'info').mockImplementation(() => {})
|
||||
jest.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('log levels', () => {
|
||||
it('should respect log level hierarchy', () => {
|
||||
const warnLogger = createStructuredLogger({ level: 'warn' })
|
||||
|
||||
expect(warnLogger.isLevelEnabled('trace')).toBe(false)
|
||||
expect(warnLogger.isLevelEnabled('debug')).toBe(false)
|
||||
expect(warnLogger.isLevelEnabled('info')).toBe(false)
|
||||
expect(warnLogger.isLevelEnabled('warn')).toBe(true)
|
||||
expect(warnLogger.isLevelEnabled('error')).toBe(true)
|
||||
expect(warnLogger.isLevelEnabled('fatal')).toBe(true)
|
||||
})
|
||||
|
||||
it('should have correct level values', () => {
|
||||
expect(LOG_LEVEL_VALUES.trace).toBeLessThan(LOG_LEVEL_VALUES.debug)
|
||||
expect(LOG_LEVEL_VALUES.debug).toBeLessThan(LOG_LEVEL_VALUES.info)
|
||||
expect(LOG_LEVEL_VALUES.info).toBeLessThan(LOG_LEVEL_VALUES.warn)
|
||||
expect(LOG_LEVEL_VALUES.warn).toBeLessThan(LOG_LEVEL_VALUES.error)
|
||||
expect(LOG_LEVEL_VALUES.error).toBeLessThan(LOG_LEVEL_VALUES.fatal)
|
||||
})
|
||||
|
||||
it('should allow level to be changed', () => {
|
||||
expect(logger.level).toBe('debug')
|
||||
logger.level = 'error'
|
||||
expect(logger.level).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logging methods', () => {
|
||||
it('should log info messages', () => {
|
||||
logger.info({ test: 'data' }, 'Test message')
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log with object data', () => {
|
||||
logger.debug({ key: 'value', number: 42 })
|
||||
expect(console.debug).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log errors with stack trace', () => {
|
||||
const error = new Error('Test error')
|
||||
logger.error(error, 'Error occurred')
|
||||
expect(console.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('child loggers', () => {
|
||||
it('should create child logger with additional context', () => {
|
||||
const child = logger.child({ component: 'child' })
|
||||
expect(child).toBeInstanceOf(StructuredLogger)
|
||||
})
|
||||
|
||||
it('should inherit parent level', () => {
|
||||
logger.level = 'warn'
|
||||
const child = logger.child({ component: 'test' })
|
||||
expect(child.level).toBe('warn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitization', () => {
|
||||
it('should redact sensitive fields', () => {
|
||||
const jsonLogger = createStructuredLogger({
|
||||
level: 'info',
|
||||
jsonFormat: true,
|
||||
})
|
||||
|
||||
// O logger deve sanitizar campos sensíveis
|
||||
jsonLogger.info({
|
||||
user: 'test',
|
||||
password: 'secret123',
|
||||
token: 'abc123',
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('metrics', () => {
|
||||
it('should track log metrics', () => {
|
||||
logger.info({}, 'test 1')
|
||||
logger.warn({}, 'test 2')
|
||||
logger.error({}, 'test 3')
|
||||
|
||||
const metrics = logger.getMetrics()
|
||||
|
||||
expect(metrics.totalLogs).toBe(3)
|
||||
expect(metrics.logsByLevel.info).toBe(1)
|
||||
expect(metrics.logsByLevel.warn).toBe(1)
|
||||
expect(metrics.logsByLevel.error).toBe(1)
|
||||
expect(metrics.errorsCount).toBe(1)
|
||||
})
|
||||
|
||||
it('should reset metrics', () => {
|
||||
logger.info({}, 'test')
|
||||
logger.resetMetrics()
|
||||
|
||||
const metrics = logger.getMetrics()
|
||||
expect(metrics.totalLogs).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('context helpers', () => {
|
||||
it('should create logger with context', () => {
|
||||
const contextLogger = logger.withContext({ requestId: '123' })
|
||||
expect(contextLogger).toBeInstanceOf(StructuredLogger)
|
||||
})
|
||||
|
||||
it('should create logger with correlation ID', () => {
|
||||
const correlationLogger = logger.withCorrelationId('corr-123')
|
||||
expect(correlationLogger).toBeInstanceOf(StructuredLogger)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logOperation', () => {
|
||||
it('should log operation start and complete', async () => {
|
||||
const result = await logger.logOperation('test-op', async () => {
|
||||
return 'result'
|
||||
})
|
||||
|
||||
expect(result).toBe('result')
|
||||
})
|
||||
|
||||
it('should log operation error', async () => {
|
||||
await expect(
|
||||
logger.logOperation('failing-op', async () => {
|
||||
throw new Error('Operation failed')
|
||||
})
|
||||
).rejects.toThrow('Operation failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTimer', () => {
|
||||
it('should measure elapsed time', async () => {
|
||||
const timer = createTimer()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
const elapsed = timer.elapsed()
|
||||
expect(elapsed).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should format elapsed time', async () => {
|
||||
const timer = createTimer()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
const formatted = timer.elapsedMs()
|
||||
expect(formatted).toMatch(/\d+\.\d+ms/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default logger', () => {
|
||||
it('should get default logger', () => {
|
||||
const defaultLogger = getDefaultLogger()
|
||||
expect(defaultLogger).toBeInstanceOf(StructuredLogger)
|
||||
})
|
||||
|
||||
it('should set default logger', () => {
|
||||
const customLogger = createStructuredLogger({ name: 'custom' })
|
||||
setDefaultLogger(customLogger)
|
||||
|
||||
const retrieved = getDefaultLogger()
|
||||
expect(retrieved).toBe(customLogger)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Testes unitários para trace-context.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals'
|
||||
import {
|
||||
createTraceContext,
|
||||
getCurrentContext,
|
||||
getOrCreateContext,
|
||||
runWithContext,
|
||||
runWithNewContext,
|
||||
createSpan,
|
||||
startSpan,
|
||||
endSpan,
|
||||
addSpanEvent,
|
||||
setSpanAttributes,
|
||||
setSpanError,
|
||||
withSpan,
|
||||
withSpanSync,
|
||||
setBaggage,
|
||||
getBaggage,
|
||||
getAllBaggage,
|
||||
removeBaggage,
|
||||
injectTraceHeaders,
|
||||
extractTraceHeaders,
|
||||
exportContext,
|
||||
importContext,
|
||||
createPrecisionTimer,
|
||||
generateTraceId,
|
||||
generateSpanId,
|
||||
generateCorrelationId,
|
||||
TRACE_HEADERS,
|
||||
type TraceContext,
|
||||
type Span,
|
||||
} from '../../Utils/trace-context.js'
|
||||
|
||||
describe('ID generation', () => {
|
||||
describe('generateTraceId', () => {
|
||||
it('should generate 32 character hex string', () => {
|
||||
const id = generateTraceId()
|
||||
expect(id).toHaveLength(32)
|
||||
expect(id).toMatch(/^[0-9a-f]+$/)
|
||||
})
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const ids = new Set()
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ids.add(generateTraceId())
|
||||
}
|
||||
expect(ids.size).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateSpanId', () => {
|
||||
it('should generate 16 character hex string', () => {
|
||||
const id = generateSpanId()
|
||||
expect(id).toHaveLength(16)
|
||||
expect(id).toMatch(/^[0-9a-f]+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateCorrelationId', () => {
|
||||
it('should generate readable correlation ID', () => {
|
||||
const id = generateCorrelationId()
|
||||
expect(id).toMatch(/^[a-z0-9]+-[a-z0-9]+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('TraceContext', () => {
|
||||
describe('createTraceContext', () => {
|
||||
it('should create context with generated IDs', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
expect(context.traceIds.traceId).toHaveLength(32)
|
||||
expect(context.traceIds.spanId).toHaveLength(16)
|
||||
expect(context.traceIds.correlationId).toBeTruthy()
|
||||
expect(context.baggage).toEqual({})
|
||||
expect(context.spanStack).toEqual([])
|
||||
})
|
||||
|
||||
it('should use provided options', () => {
|
||||
const context = createTraceContext({
|
||||
traceId: 'custom-trace-id',
|
||||
correlationId: 'custom-corr-id',
|
||||
parentSpanId: 'parent-span',
|
||||
baggage: { key: 'value' },
|
||||
metadata: { env: 'test' },
|
||||
})
|
||||
|
||||
expect(context.traceIds.traceId).toBe('custom-trace-id')
|
||||
expect(context.traceIds.correlationId).toBe('custom-corr-id')
|
||||
expect(context.traceIds.parentSpanId).toBe('parent-span')
|
||||
expect(context.baggage).toEqual({ key: 'value' })
|
||||
expect(context.metadata).toEqual({ env: 'test' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWithContext', () => {
|
||||
it('should make context available within scope', () => {
|
||||
const context = createTraceContext()
|
||||
let captured: TraceContext | undefined
|
||||
|
||||
runWithContext(context, () => {
|
||||
captured = getCurrentContext()
|
||||
})
|
||||
|
||||
expect(captured).toBe(context)
|
||||
})
|
||||
|
||||
it('should not leak context outside scope', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
runWithContext(context, () => {
|
||||
// Context available here
|
||||
})
|
||||
|
||||
// Context should be undefined outside
|
||||
expect(getCurrentContext()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return value from function', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
const result = runWithContext(context, () => 'result')
|
||||
|
||||
expect(result).toBe('result')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWithNewContext', () => {
|
||||
it('should create and run with new context', () => {
|
||||
let captured: TraceContext | undefined
|
||||
|
||||
runWithNewContext({ baggage: { test: 'value' } }, () => {
|
||||
captured = getCurrentContext()
|
||||
})
|
||||
|
||||
expect(captured).toBeDefined()
|
||||
expect(captured!.baggage.test).toBe('value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrCreateContext', () => {
|
||||
it('should return existing context if available', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
runWithContext(context, () => {
|
||||
const retrieved = getOrCreateContext()
|
||||
expect(retrieved).toBe(context)
|
||||
})
|
||||
})
|
||||
|
||||
it('should create new context if none exists', () => {
|
||||
const context = getOrCreateContext()
|
||||
expect(context).toBeDefined()
|
||||
expect(context.traceIds.traceId).toHaveLength(32)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Spans', () => {
|
||||
describe('createSpan', () => {
|
||||
it('should create span with provided name', () => {
|
||||
const span = createSpan({ name: 'test-span' })
|
||||
|
||||
expect(span.name).toBe('test-span')
|
||||
expect(span.traceIds.spanId).toHaveLength(16)
|
||||
expect(span.startTime).toBeGreaterThan(0)
|
||||
expect(span.status).toBe('unset')
|
||||
expect(span.ended).toBe(false)
|
||||
})
|
||||
|
||||
it('should include provided attributes', () => {
|
||||
const span = createSpan({
|
||||
name: 'test',
|
||||
attributes: { key: 'value', count: 42 },
|
||||
})
|
||||
|
||||
expect(span.attributes).toEqual({ key: 'value', count: 42 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSpan', () => {
|
||||
it('should start span and set as current in context', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
runWithContext(context, () => {
|
||||
const span = startSpan({ name: 'operation' })
|
||||
|
||||
expect(getCurrentContext()!.currentSpan).toBe(span)
|
||||
})
|
||||
})
|
||||
|
||||
it('should support nested spans', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
runWithContext(context, () => {
|
||||
const parent = startSpan({ name: 'parent' })
|
||||
const child = startSpan({ name: 'child' })
|
||||
|
||||
expect(child.traceIds.parentSpanId).toBe(parent.traceIds.spanId)
|
||||
expect(getCurrentContext()!.spanStack).toContain(parent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('endSpan', () => {
|
||||
it('should mark span as ended', () => {
|
||||
const span = createSpan({ name: 'test' })
|
||||
endSpan(span)
|
||||
|
||||
expect(span.ended).toBe(true)
|
||||
expect(span.endTime).toBeDefined()
|
||||
expect(span.duration).toBeDefined()
|
||||
expect(span.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('should set custom status', () => {
|
||||
const span = createSpan({ name: 'test' })
|
||||
endSpan(span, 'error')
|
||||
|
||||
expect(span.status).toBe('error')
|
||||
})
|
||||
|
||||
it('should not modify already ended span', () => {
|
||||
const span = createSpan({ name: 'test' })
|
||||
endSpan(span, 'ok')
|
||||
|
||||
const endTime = span.endTime
|
||||
|
||||
endSpan(span, 'error')
|
||||
|
||||
expect(span.endTime).toBe(endTime)
|
||||
expect(span.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('should pop span from stack', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
runWithContext(context, () => {
|
||||
const parent = startSpan({ name: 'parent' })
|
||||
const child = startSpan({ name: 'child' })
|
||||
|
||||
endSpan(child)
|
||||
|
||||
expect(getCurrentContext()!.currentSpan).toBe(parent)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('addSpanEvent', () => {
|
||||
it('should add event to span', () => {
|
||||
const span = createSpan({ name: 'test' })
|
||||
addSpanEvent(span, 'event-occurred', { detail: 'value' })
|
||||
|
||||
expect(span.events).toHaveLength(1)
|
||||
expect(span.events[0].name).toBe('event-occurred')
|
||||
expect(span.events[0].attributes).toEqual({ detail: 'value' })
|
||||
})
|
||||
|
||||
it('should not add event to ended span', () => {
|
||||
const span = createSpan({ name: 'test' })
|
||||
endSpan(span)
|
||||
addSpanEvent(span, 'late-event')
|
||||
|
||||
expect(span.events).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSpanAttributes', () => {
|
||||
it('should set attributes on span', () => {
|
||||
const span = createSpan({ name: 'test' })
|
||||
setSpanAttributes(span, { key1: 'value1', key2: 'value2' })
|
||||
|
||||
expect(span.attributes.key1).toBe('value1')
|
||||
expect(span.attributes.key2).toBe('value2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSpanError', () => {
|
||||
it('should mark span as error with details', () => {
|
||||
const span = createSpan({ name: 'test' })
|
||||
const error = new Error('Something went wrong')
|
||||
setSpanError(span, error)
|
||||
|
||||
expect(span.status).toBe('error')
|
||||
expect(span.attributes.error).toBe(true)
|
||||
expect(span.attributes.errorMessage).toBe('Something went wrong')
|
||||
expect(span.events.some((e) => e.name === 'exception')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('withSpan helpers', () => {
|
||||
describe('withSpan', () => {
|
||||
it('should execute async operation within span', async () => {
|
||||
const result = await withSpan('async-op', async (span) => {
|
||||
expect(span.name).toBe('async-op')
|
||||
return 'async-result'
|
||||
})
|
||||
|
||||
expect(result).toBe('async-result')
|
||||
})
|
||||
|
||||
it('should handle errors and mark span as error', async () => {
|
||||
await expect(
|
||||
withSpan('failing-op', async () => {
|
||||
throw new Error('Failure')
|
||||
})
|
||||
).rejects.toThrow('Failure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withSpanSync', () => {
|
||||
it('should execute sync operation within span', () => {
|
||||
const result = withSpanSync('sync-op', (span) => {
|
||||
expect(span.name).toBe('sync-op')
|
||||
return 'sync-result'
|
||||
})
|
||||
|
||||
expect(result).toBe('sync-result')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Baggage', () => {
|
||||
it('should set and get baggage', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
runWithContext(context, () => {
|
||||
setBaggage('userId', 'user-123')
|
||||
setBaggage('requestType', 'api')
|
||||
|
||||
expect(getBaggage('userId')).toBe('user-123')
|
||||
expect(getBaggage('requestType')).toBe('api')
|
||||
})
|
||||
})
|
||||
|
||||
it('should get all baggage', () => {
|
||||
const context = createTraceContext({ baggage: { existing: 'value' } })
|
||||
|
||||
runWithContext(context, () => {
|
||||
setBaggage('new', 'item')
|
||||
|
||||
const all = getAllBaggage()
|
||||
|
||||
expect(all).toEqual({ existing: 'value', new: 'item' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove baggage', () => {
|
||||
const context = createTraceContext({ baggage: { toRemove: 'value' } })
|
||||
|
||||
runWithContext(context, () => {
|
||||
removeBaggage('toRemove')
|
||||
|
||||
expect(getBaggage('toRemove')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Header injection/extraction', () => {
|
||||
describe('injectTraceHeaders', () => {
|
||||
it('should inject trace headers', () => {
|
||||
const context = createTraceContext({
|
||||
baggage: { user: 'test' },
|
||||
})
|
||||
|
||||
const headers = runWithContext(context, () => {
|
||||
return injectTraceHeaders({})
|
||||
})
|
||||
|
||||
expect(headers[TRACE_HEADERS.TRACE_ID]).toBe(context.traceIds.traceId)
|
||||
expect(headers[TRACE_HEADERS.SPAN_ID]).toBe(context.traceIds.spanId)
|
||||
expect(headers[TRACE_HEADERS.CORRELATION_ID]).toBe(context.traceIds.correlationId)
|
||||
expect(headers[TRACE_HEADERS.BAGGAGE]).toContain('user=test')
|
||||
})
|
||||
|
||||
it('should preserve existing headers', () => {
|
||||
const context = createTraceContext()
|
||||
|
||||
const headers = runWithContext(context, () => {
|
||||
return injectTraceHeaders({ 'Content-Type': 'application/json' })
|
||||
})
|
||||
|
||||
expect(headers['Content-Type']).toBe('application/json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTraceHeaders', () => {
|
||||
it('should extract trace context from headers', () => {
|
||||
const headers = {
|
||||
[TRACE_HEADERS.TRACE_ID]: 'trace-123',
|
||||
[TRACE_HEADERS.PARENT_SPAN_ID]: 'parent-456',
|
||||
[TRACE_HEADERS.CORRELATION_ID]: 'corr-789',
|
||||
[TRACE_HEADERS.BAGGAGE]: 'key1=value1,key2=value2',
|
||||
}
|
||||
|
||||
const options = extractTraceHeaders(headers)
|
||||
|
||||
expect(options.traceId).toBe('trace-123')
|
||||
expect(options.parentSpanId).toBe('parent-456')
|
||||
expect(options.correlationId).toBe('corr-789')
|
||||
expect(options.baggage).toEqual({ key1: 'value1', key2: 'value2' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Context serialization', () => {
|
||||
describe('exportContext', () => {
|
||||
it('should serialize context to JSON string', () => {
|
||||
const context = createTraceContext({
|
||||
baggage: { key: 'value' },
|
||||
metadata: { env: 'test' },
|
||||
})
|
||||
|
||||
const exported = exportContext(context)
|
||||
const parsed = JSON.parse(exported)
|
||||
|
||||
expect(parsed.traceIds).toEqual(context.traceIds)
|
||||
expect(parsed.baggage).toEqual(context.baggage)
|
||||
expect(parsed.metadata).toEqual(context.metadata)
|
||||
})
|
||||
})
|
||||
|
||||
describe('importContext', () => {
|
||||
it('should deserialize context from JSON string', () => {
|
||||
const serialized = JSON.stringify({
|
||||
traceIds: {
|
||||
traceId: 'trace-123',
|
||||
spanId: 'span-456',
|
||||
correlationId: 'corr-789',
|
||||
},
|
||||
baggage: { imported: 'value' },
|
||||
metadata: { source: 'external' },
|
||||
})
|
||||
|
||||
const options = importContext(serialized)
|
||||
|
||||
expect(options.traceId).toBe('trace-123')
|
||||
expect(options.parentSpanId).toBe('span-456')
|
||||
expect(options.correlationId).toBe('corr-789')
|
||||
expect(options.baggage).toEqual({ imported: 'value' })
|
||||
})
|
||||
|
||||
it('should handle invalid JSON gracefully', () => {
|
||||
const options = importContext('invalid json')
|
||||
expect(options).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PrecisionTimer', () => {
|
||||
it('should measure elapsed time', async () => {
|
||||
const timer = createPrecisionTimer()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
const elapsed = timer.elapsed()
|
||||
expect(elapsed).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('should format elapsed time', async () => {
|
||||
const timer = createPrecisionTimer()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
const formatted = timer.elapsedFormatted()
|
||||
expect(formatted).toMatch(/\d+(\.\d+)?(µs|ms|s)/)
|
||||
})
|
||||
|
||||
it('should stop timer', async () => {
|
||||
const timer = createPrecisionTimer()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
const stopped = timer.stop()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
const afterStop = timer.elapsed()
|
||||
|
||||
expect(afterStop).toBe(stopped)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user