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:
Renato Alcara
2026-01-19 22:55:48 -03:00
committed by GitHub
16 changed files with 7946 additions and 0 deletions
+741
View File
@@ -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
+505
View File
@@ -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
+519
View File
@@ -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
+753
View File
@@ -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
+19
View File
@@ -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'
+373
View File
@@ -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
+785
View File
@@ -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
+636
View File
@@ -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
+495
View File
@@ -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
+657
View File
@@ -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')
})
})
})
+295
View File
@@ -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()
})
})
+371
View File
@@ -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')
})
})
+466
View File
@@ -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)
})
})
})
+486
View File
@@ -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)
})
})