From 882a1f57cf7041b9e9ec6bd9383423501ad3996e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 01:26:06 +0000 Subject: [PATCH] feat(utils): add observability and resilience utilities Implement comprehensive utility modules for InfiniteAPI: Logging: - structured-logger: JSON logging with levels, sanitization, metrics - logger-adapter: Pino/Console/Structured adapter pattern - baileys-logger: WhatsApp-specific logger with categories Observability: - trace-context: Distributed tracing with spans, correlation IDs - prometheus-metrics: Counter, Gauge, Histogram, Summary metrics Resilience: - cache-utils: LRU cache with TTL, multi-level support - circuit-breaker: 3-state circuit breaker with fallbacks - retry-utils: Exponential backoff with jitter, predicates Event Streaming: - baileys-event-stream: Priority queues, backpressure, DLQ Includes unit tests for all new modules. --- src/Utils/baileys-event-stream.ts | 740 +++++++++++++++++ src/Utils/baileys-logger.ts | 493 +++++++++++ src/Utils/cache-utils.ts | 518 ++++++++++++ src/Utils/circuit-breaker.ts | 532 ++++++++++++ src/Utils/index.ts | 19 + src/Utils/logger-adapter.ts | 373 +++++++++ src/Utils/prometheus-metrics.ts | 784 ++++++++++++++++++ src/Utils/retry-utils.ts | 635 ++++++++++++++ src/Utils/structured-logger.ts | 482 +++++++++++ src/Utils/trace-context.ts | 656 +++++++++++++++ .../Utils/baileys-event-stream.test.ts | 645 ++++++++++++++ src/__tests__/Utils/cache-utils.test.ts | 295 +++++++ src/__tests__/Utils/circuit-breaker.test.ts | 371 +++++++++ src/__tests__/Utils/retry-utils.test.ts | 466 +++++++++++ src/__tests__/Utils/structured-logger.test.ts | 200 +++++ src/__tests__/Utils/trace-context.test.ts | 486 +++++++++++ 16 files changed, 7695 insertions(+) create mode 100644 src/Utils/baileys-event-stream.ts create mode 100644 src/Utils/baileys-logger.ts create mode 100644 src/Utils/cache-utils.ts create mode 100644 src/Utils/circuit-breaker.ts create mode 100644 src/Utils/logger-adapter.ts create mode 100644 src/Utils/prometheus-metrics.ts create mode 100644 src/Utils/retry-utils.ts create mode 100644 src/Utils/structured-logger.ts create mode 100644 src/Utils/trace-context.ts create mode 100644 src/__tests__/Utils/baileys-event-stream.test.ts create mode 100644 src/__tests__/Utils/cache-utils.test.ts create mode 100644 src/__tests__/Utils/circuit-breaker.test.ts create mode 100644 src/__tests__/Utils/retry-utils.test.ts create mode 100644 src/__tests__/Utils/structured-logger.test.ts create mode 100644 src/__tests__/Utils/trace-context.test.ts diff --git a/src/Utils/baileys-event-stream.ts b/src/Utils/baileys-event-stream.ts new file mode 100644 index 00000000..85734206 --- /dev/null +++ b/src/Utils/baileys-event-stream.ts @@ -0,0 +1,740 @@ +/** + * @fileoverview Gerenciamento de eventos do Baileys + * @module Utils/baileys-event-stream + * + * Fornece: + * - Event buffering com backpressure + * - Event transformation e filtering + * - Priority queues para eventos + * - Batch processing + * - Dead letter queue para eventos falhos + * - Replay de eventos + * - Integração com logging e métricas + */ + +import { EventEmitter } from 'events' +import { metrics } from './prometheus-metrics.js' +import type { BaileysLogCategory } from './baileys-logger.js' + +/** + * Tipos de eventos do Baileys + */ +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 // Para eventos customizados + +/** + * Prioridade de eventos + */ +export type EventPriority = 'critical' | 'high' | 'normal' | 'low' + +/** + * Valores numéricos de prioridade + */ +const PRIORITY_VALUES: Record = { + critical: 0, + high: 1, + normal: 2, + low: 3, +} + +/** + * Evento do stream + */ +export interface StreamEvent { + id: string + type: BaileysEventType + data: T + timestamp: number + priority: EventPriority + category: BaileysLogCategory + metadata?: Record + retryCount?: number + originalTimestamp?: number +} + +/** + * Opções do Event Stream + */ +export interface EventStreamOptions { + /** Tamanho máximo do buffer (default: 10000) */ + maxBufferSize?: number + /** Se deve aplicar backpressure quando buffer cheio */ + enableBackpressure?: boolean + /** Limite de highWaterMark para backpressure */ + highWaterMark?: number + /** Limite de lowWaterMark para retomar */ + lowWaterMark?: number + /** Tamanho do batch para processamento */ + batchSize?: number + /** Intervalo de flush em ms (0 = desabilitado) */ + flushInterval?: number + /** Máximo de retries para eventos falhos */ + maxRetries?: number + /** Tamanho da dead letter queue */ + deadLetterQueueSize?: number + /** Se deve coletar métricas */ + collectMetrics?: boolean + /** Nome do stream para métricas */ + streamName?: string +} + +/** + * Handler de evento + */ +export type EventHandler = (event: StreamEvent) => void | Promise + +/** + * Filtro de evento + */ +export type EventFilter = (event: StreamEvent) => boolean + +/** + * Transformador de evento + */ +export type EventTransformer = (event: StreamEvent) => StreamEvent + +/** + * Resultado de processamento de batch + */ +export interface BatchResult { + processed: number + failed: number + duration: number +} + +/** + * Estatísticas do stream + */ +export interface EventStreamStats { + bufferSize: number + totalReceived: number + totalProcessed: number + totalFailed: number + totalDropped: number + deadLetterQueueSize: number + isBackpressured: boolean + lastEventTimestamp?: number + eventsByType: Record + eventsByPriority: Record +} + +/** + * Mapeamento de tipo de evento para categoria + */ +const EVENT_CATEGORY_MAP: Record = { + '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', +} + +/** + * Prioridade padrão por tipo de evento + */ +const EVENT_PRIORITY_MAP: Partial> = { + 'connection.update': 'critical', + 'creds.update': 'critical', + 'messages.upsert': 'high', + 'messages.update': 'high', + 'call': 'high', + 'presence.update': 'low', + 'messaging-history.set': 'normal', +} + +/** + * Gera ID único para evento + */ +function generateEventId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}` +} + +/** + * Classe principal do Event Stream + */ +export class BaileysEventStream extends EventEmitter { + private buffer: StreamEvent[] = [] + private handlers: Map> = new Map() + private filters: EventFilter[] = [] + private transformers: EventTransformer[] = [] + private deadLetterQueue: StreamEvent[] = [] + private options: Required + private stats: EventStreamStats + private isProcessing = false + private flushTimer?: ReturnType + 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() + + // Iniciar flush periódico se configurado + 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, + }, + } + } + + /** + * Adiciona evento ao stream + */ + push(type: BaileysEventType, data: T, options?: { priority?: EventPriority; metadata?: Record }): boolean { + // Verificar 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 = { + 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, + } + + // Aplicar transformadores + let transformedEvent: StreamEvent = event + for (const transformer of this.transformers) { + transformedEvent = transformer(transformedEvent) + } + + // Aplicar filtros + for (const filter of this.filters) { + if (!filter(transformedEvent)) { + return false + } + } + + // Adicionar ao buffer na posição correta (por prioridade) + this.insertByPriority(transformedEvent) + + // Atualizar estatísticas + 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) + + // Processar se não estiver pausado + if (!this.paused && !this.isProcessing) { + this.processNext() + } + + return true + } + + /** + * Insere evento no buffer por prioridade + */ + private insertByPriority(event: StreamEvent): void { + const eventPriorityValue = PRIORITY_VALUES[event.priority] + + // Encontrar posição correta + 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) + } + + /** + * Registra handler para tipo de evento + */ + on(event: BaileysEventType | '*', handler: EventHandler): 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 + } + + /** + * Registra handler único + */ + once(event: BaileysEventType, handler: EventHandler): this { + const wrappedHandler: EventHandler = (e) => { + this.off(event, wrappedHandler as EventHandler) + return handler(e) + } + return this.on(event, wrappedHandler) + } + + /** + * Adiciona filtro + */ + addFilter(filter: EventFilter): this { + this.filters.push(filter) + return this + } + + /** + * Remove filtro + */ + removeFilter(filter: EventFilter): this { + const index = this.filters.indexOf(filter) + if (index !== -1) { + this.filters.splice(index, 1) + } + return this + } + + /** + * Adiciona transformador + */ + addTransformer(transformer: EventTransformer): this { + this.transformers.push(transformer) + return this + } + + /** + * Processa próximos eventos + */ + private async processNext(): Promise { + if (this.isProcessing || this.paused || this.buffer.length === 0) { + return + } + + this.isProcessing = true + + try { + // Pegar batch de eventos + const batch = this.buffer.splice(0, this.options.batchSize) + this.stats.bufferSize = this.buffer.length + + // Verificar se saiu de backpressure + if (this.stats.isBackpressured && this.buffer.length <= this.options.lowWaterMark) { + this.stats.isBackpressured = false + this.emit('drain') + } + + // Processar 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) + + // Continuar processando se houver mais + if (this.buffer.length > 0) { + setImmediate(() => this.processNext()) + } + } finally { + this.isProcessing = false + } + } + + /** + * Processa um evento + */ + private async processEvent(event: StreamEvent): Promise { + // Handlers específicos do tipo + const typeHandlers = this.handlers.get(event.type) + if (typeHandlers) { + for (const handler of typeHandlers) { + await handler(event) + } + } + + // Handlers globais + const globalHandlers = this.handlers.get('*') + if (globalHandlers) { + for (const handler of globalHandlers) { + await handler(event) + } + } + } + + /** + * Trata evento que falhou + */ + private async handleFailedEvent(event: StreamEvent, error: Error): Promise { + event.retryCount = (event.retryCount || 0) + 1 + + if (event.retryCount <= this.options.maxRetries) { + // Re-adicionar ao buffer para 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 { + // Enviar para dead letter queue + this.addToDeadLetterQueue(event, error) + } + + if (this.options.collectMetrics) { + metrics.errors.inc({ category: 'event_stream', code: 'processing_failed' }) + } + } + + /** + * Adiciona evento à 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) + + // Limitar tamanho da DLQ + while (this.deadLetterQueue.length > this.options.deadLetterQueueSize) { + this.deadLetterQueue.shift() + } + + this.stats.deadLetterQueueSize = this.deadLetterQueue.length + this.emit('dead-letter', dlqEvent) + } + + /** + * Força flush do buffer + */ + async flush(): Promise { + 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, + } + } + + /** + * Pausa o processamento + */ + pause(): void { + this.paused = true + this.emit('pause') + } + + /** + * Resume o processamento + */ + resume(): void { + this.paused = false + this.emit('resume') + this.processNext() + } + + /** + * Verifica se está pausado + */ + isPaused(): boolean { + return this.paused + } + + /** + * Limpa o buffer + */ + clear(): void { + this.buffer = [] + this.stats.bufferSize = 0 + this.emit('clear') + } + + /** + * Retorna eventos da dead letter queue + */ + getDeadLetterQueue(): StreamEvent[] { + return [...this.deadLetterQueue] + } + + /** + * Limpa dead letter queue + */ + clearDeadLetterQueue(): void { + this.deadLetterQueue = [] + this.stats.deadLetterQueueSize = 0 + } + + /** + * Replay eventos da dead letter queue + */ + async replayDeadLetterQueue(): Promise { + 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, + } + } + + /** + * Retorna estatísticas + */ + getStats(): EventStreamStats { + return { ...this.stats } + } + + /** + * Reseta estatísticas + */ + resetStats(): void { + this.stats = this.createInitialStats() + this.stats.bufferSize = this.buffer.length + this.stats.deadLetterQueueSize = this.deadLetterQueue.length + } + + /** + * Destroy e limpa recursos + */ + destroy(): void { + if (this.flushTimer) { + clearInterval(this.flushTimer) + } + this.buffer = [] + this.deadLetterQueue = [] + this.handlers.clear() + this.filters = [] + this.transformers = [] + this.removeAllListeners() + } +} + +/** + * Factory para criar event stream + */ +export function createEventStream(options?: EventStreamOptions): BaileysEventStream { + return new BaileysEventStream(options) +} + +/** + * Filtros pré-definidos + */ +export const eventFilters = { + /** Filtra por tipo de evento */ + byType: + (...types: BaileysEventType[]): EventFilter => + (event) => + types.includes(event.type), + + /** Filtra por categoria */ + byCategory: + (...categories: BaileysLogCategory[]): EventFilter => + (event) => + categories.includes(event.category), + + /** Filtra por prioridade mínima */ + byMinPriority: + (minPriority: EventPriority): EventFilter => + (event) => + PRIORITY_VALUES[event.priority] <= PRIORITY_VALUES[minPriority], + + /** Filtra eventos recentes (dentro de ms) */ + recentOnly: + (maxAgeMs: number): EventFilter => + (event) => + Date.now() - event.timestamp <= maxAgeMs, + + /** Combina filtros com AND */ + and: + (...filters: EventFilter[]): EventFilter => + (event) => + filters.every((f) => f(event)), + + /** Combina filtros com OR */ + or: + (...filters: EventFilter[]): EventFilter => + (event) => + filters.some((f) => f(event)), +} + +/** + * Transformadores pré-definidos + */ +export const eventTransformers = { + /** Adiciona timestamp de processamento */ + addProcessingTimestamp: (): EventTransformer => (event) => ({ + ...event, + metadata: { + ...event.metadata, + processingTimestamp: Date.now(), + }, + }), + + /** Adiciona ID de trace */ + addTraceId: + (traceIdGenerator: () => string): EventTransformer => + (event) => ({ + ...event, + metadata: { + ...event.metadata, + traceId: traceIdGenerator(), + }, + }), + + /** Eleva prioridade baseado em condição */ + elevatepriorityIf: + (condition: (event: StreamEvent) => boolean, newPriority: EventPriority): EventTransformer => + (event) => + condition(event) + ? { ...event, priority: newPriority } + : event, +} + +export default BaileysEventStream diff --git a/src/Utils/baileys-logger.ts b/src/Utils/baileys-logger.ts new file mode 100644 index 00000000..ea0157c4 --- /dev/null +++ b/src/Utils/baileys-logger.ts @@ -0,0 +1,493 @@ +/** + * @fileoverview Logger customizado específico para Baileys + * @module Utils/baileys-logger + * + * Fornece: + * - Logger pré-configurado para contexto Baileys/WhatsApp + * - Categorização de eventos por tipo (connection, message, media, etc.) + * - Filtros específicos para reduzir ruído + * - Métricas de eventos WhatsApp + * - Formatação otimizada para debugging + */ + +import type { ILogger } from './logger.js' +import { StructuredLogger, createStructuredLogger, type LogLevel, type LogEntry } from './structured-logger.js' + +/** + * Categorias de log específicas do Baileys + */ +export type BaileysLogCategory = + | 'connection' // Eventos de conexão WebSocket + | 'auth' // Autenticação e QR code + | 'message' // Envio/recebimento de mensagens + | 'media' // Upload/download de mídia + | 'group' // Operações de grupo + | 'presence' // Status de presença + | 'call' // Chamadas de voz/vídeo + | 'sync' // Sincronização de dados + | 'encryption' // Operações de criptografia + | 'retry' // Retentativas de operações + | 'socket' // Eventos de socket baixo nível + | 'binary' // Codificação/decodificação binária + | 'unknown' // Categoria desconhecida + +/** + * Configuração do Baileys Logger + */ +export interface BaileysLoggerConfig { + /** Nível de log padrão */ + level: LogLevel + /** Categorias a serem ignoradas */ + ignoredCategories?: BaileysLogCategory[] + /** Categorias com nível de log elevado (debug sempre) */ + verboseCategories?: BaileysLogCategory[] + /** Se deve logar payloads de mensagens (pode ser sensível) */ + logMessagePayloads?: boolean + /** Se deve logar dados binários em hex */ + logBinaryData?: boolean + /** Prefixo para identificar instância */ + instanceId?: string + /** Handler para eventos específicos */ + eventHandler?: (category: BaileysLogCategory, entry: LogEntry) => void + /** Limite de tamanho para payloads logados (bytes) */ + maxPayloadSize?: number +} + +/** + * Métricas específicas do Baileys + */ +export interface BaileysLoggerMetrics { + connectionAttempts: number + connectionSuccesses: number + connectionFailures: number + messagesSent: number + messagesReceived: number + mediaUploads: number + mediaDownloads: number + retryAttempts: number + encryptionOperations: number + errorsByCategory: Record + lastConnectionTime?: string + lastMessageTime?: string +} + +/** + * Padrões para detectar categoria de log + */ +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' }, +] + +/** + * Logger customizado para Baileys + */ +export class BaileysLogger implements ILogger { + private structuredLogger: StructuredLogger + private config: Required + private metrics: BaileysLoggerMetrics + private childContext: Record = {} + + constructor(config: Partial = {}) { + 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 + } + + /** + * Cria logger filho com contexto adicional + */ + child(obj: Record): BaileysLogger { + const childLogger = new BaileysLogger(this.config) + childLogger.childContext = { ...this.childContext, ...obj } + return childLogger + } + + /** + * Detecta categoria do log baseado no conteúdo + */ + 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' + } + + /** + * Verifica se categoria deve ser logada + */ + private shouldLogCategory(category: BaileysLogCategory, level: LogLevel): boolean { + if (this.config.ignoredCategories.includes(category)) { + return false + } + + // Categorias verbose sempre logam em debug ou superior + if (this.config.verboseCategories.includes(category)) { + return true + } + + return true + } + + /** + * Sanitiza payload de mensagem + */ + private sanitizePayload(obj: unknown): unknown { + if (!this.config.logMessagePayloads) { + if (typeof obj === 'object' && obj !== null) { + const sanitized = { ...(obj as Record) } + + // Remover campos sensíveis de mensagem + 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 + } + } + + // Limitar tamanho do payload + 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 + } + + /** + * Atualiza métricas baseado no 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]++ + } + } + + /** + * Método principal de log + */ + private log(level: LogLevel, obj: unknown, msg?: string): void { + const category = this.detectCategory(obj, msg) + + if (!this.shouldLogCategory(category, level)) { + return + } + + // Atualizar métricas + this.updateMetrics(category, level, obj) + + // Sanitizar payload + const sanitizedObj = this.sanitizePayload(obj) + + // Adicionar contexto do Baileys + const enrichedObj = { + category, + instanceId: this.config.instanceId, + ...this.childContext, + ...(typeof sanitizedObj === 'object' && sanitizedObj !== null ? sanitizedObj : { value: sanitizedObj }), + } + + // Log estruturado + this.structuredLogger[level](enrichedObj, msg) + + // Handler de evento + 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 de conexão específico + */ + logConnection(event: 'connecting' | 'connected' | 'disconnected' | 'error', details?: Record): void { + const level: LogLevel = event === 'error' ? 'error' : event === 'disconnected' ? 'warn' : 'info' + this.log(level, { event, ...details, category: 'connection' }, `Connection ${event}`) + } + + /** + * Log de mensagem específico + */ + logMessage( + direction: 'send' | 'receive', + messageType: string, + jid: string, + details?: Record + ): void { + const sanitizedJid = this.sanitizeJid(jid) + this.log( + 'info', + { + direction, + messageType, + jid: sanitizedJid, + ...details, + category: 'message', + }, + `Message ${direction}: ${messageType}` + ) + } + + /** + * Log de mídia específico + */ + logMedia( + operation: 'upload' | 'download', + mediaType: string, + size: number, + details?: Record + ): void { + this.log( + 'info', + { + operation, + mediaType, + sizeBytes: size, + sizeFormatted: this.formatBytes(size), + ...details, + category: 'media', + }, + `Media ${operation}: ${mediaType}` + ) + } + + /** + * Sanitiza JID para log (remove parte do número) + */ + private sanitizeJid(jid: string): string { + if (process.env.NODE_ENV === 'production') { + // Em produção, mascara parte do número + const parts = jid.split('@') + if (parts.length === 2 && parts[0].length > 4) { + return `${parts[0].substring(0, 4)}****@${parts[1]}` + } + } + return jid + } + + /** + * Formata bytes para leitura humana + */ + 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]}` + } + + /** + * Retorna métricas do logger + */ + getMetrics(): BaileysLoggerMetrics { + return { ...this.metrics } + } + + /** + * Retorna métricas do structured logger interno + */ + getStructuredMetrics() { + return this.structuredLogger.getMetrics() + } + + /** + * Reseta métricas + */ + resetMetrics(): void { + this.metrics = this.createInitialMetrics() + this.structuredLogger.resetMetrics() + } + + /** + * Retorna instance ID + */ + getInstanceId(): string { + return this.config.instanceId + } +} + +/** + * Factory para criar Baileys Logger + */ +export function createBaileysLogger(config?: Partial): BaileysLogger { + return new BaileysLogger(config) +} + +/** + * Singleton para logger padrão do Baileys + */ +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 diff --git a/src/Utils/cache-utils.ts b/src/Utils/cache-utils.ts new file mode 100644 index 00000000..6ffc04b3 --- /dev/null +++ b/src/Utils/cache-utils.ts @@ -0,0 +1,518 @@ +/** + * @fileoverview Sistema de cache inteligente + * @module Utils/cache-utils + * + * Fornece: + * - Cache em memória com TTL configurável + * - Invalidação automática e manual + * - Métricas de hit/miss + * - Estratégia LRU (Least Recently Used) + * - Cache distribuído (preparado para Redis) + * - Namespace para isolamento + * - Serialização customizável + */ + +import { LRUCache } from 'lru-cache' +import { metrics } from './prometheus-metrics.js' + +/** + * Opções de configuração do cache + */ +export interface CacheOptions { + /** Tempo de vida em ms (default: 5 minutos) */ + ttl?: number + /** Tamanho máximo do cache (default: 1000) */ + maxSize?: number + /** Função para calcular tamanho de um item */ + sizeCalculation?: (value: V) => number + /** Se deve atualizar TTL no acesso */ + updateAgeOnGet?: boolean + /** Namespace para isolamento */ + namespace?: string + /** Callback quando item expira */ + onExpire?: (key: string, value: V) => void + /** Se deve coletar métricas */ + collectMetrics?: boolean + /** Nome do cache para métricas */ + metricName?: string +} + +/** + * Estatísticas do cache + */ +export interface CacheStats { + hits: number + misses: number + size: number + maxSize: number + hitRate: number +} + +/** + * Item do cache com metadados + */ +export interface CacheItem { + value: V + createdAt: number + expiresAt: number + accessCount: number + lastAccess: number +} + +/** + * Resultado de operação de cache + */ +export interface CacheResult { + value: V | undefined + hit: boolean + expired?: boolean + key: string +} + +/** + * Classe principal do Cache + */ +export class Cache { + private cache: LRUCache> + private options: Required> + private stats: { hits: number; misses: number } + private namespace: string + + constructor(options: CacheOptions = {}) { + 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>({ + 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) + }, + }) + } + + /** + * Obtém valor do 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 + } + + /** + * Obtém valor com resultado detalhado + */ + getWithResult(key: string): CacheResult { + 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, + } + } + + /** + * Define valor no 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 = { + 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) + } + } + + /** + * Verifica se chave existe + */ + has(key: string): boolean { + const fullKey = this.getFullKey(key) + return this.cache.has(fullKey) + } + + /** + * Remove item do 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 + } + + /** + * Limpa todo o cache + */ + clear(): void { + this.cache.clear() + this.stats = { hits: 0, misses: 0 } + + if (this.options.collectMetrics) { + metrics.cacheSize.set({ cache: this.options.metricName }, 0) + } + } + + /** + * Obtém ou define valor (cache-aside pattern) + */ + async getOrSet(key: string, factory: () => V | Promise, ttl?: number): Promise { + const existing = this.get(key) + if (existing !== undefined) { + return existing + } + + const value = await factory() + this.set(key, value, ttl) + return value + } + + /** + * Obtém ou define valor síncronamente + */ + 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 + } + + /** + * Invalida itens por padrão + */ + 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 + } + + /** + * Invalida itens por prefixo + */ + invalidateByPrefix(prefix: string): number { + return this.invalidateByPattern(new RegExp(`^${prefix}`)) + } + + /** + * Retorna estatísticas do cache + */ + 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, + } + } + + /** + * Retorna tamanho atual + */ + get size(): number { + return this.cache.size + } + + /** + * Retorna todas as chaves + */ + keys(): string[] { + const prefix = `${this.namespace}:` + return Array.from(this.cache.keys()).map((k) => (k.startsWith(prefix) ? k.slice(prefix.length) : k)) + } + + /** + * Retorna todos os valores + */ + values(): V[] { + return Array.from(this.cache.values()).map((item) => item.value) + } + + /** + * Retorna todos os itens com metadados + */ + entries(): Array<{ key: string; item: CacheItem }> { + const prefix = `${this.namespace}:` + return Array.from(this.cache.entries()).map(([key, item]) => ({ + key: key.startsWith(prefix) ? key.slice(prefix.length) : key, + item, + })) + } + + /** + * Atualiza TTL de um 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 + } + + /** + * Obtém item expirado (se ainda em memória) + */ + 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 para criar cache com tipo + */ +export function createCache(options?: CacheOptions): Cache { + return new Cache(options) +} + +/** + * Cache com múltiplos níveis (L1: memória, L2: externo) + */ +export class MultiLevelCache { + private l1: Cache + private l2?: { + get: (key: string) => Promise + set: (key: string, value: V, ttl?: number) => Promise + delete: (key: string) => Promise + } + + constructor( + l1Options: CacheOptions, + l2?: { + get: (key: string) => Promise + set: (key: string, value: V, ttl?: number) => Promise + delete: (key: string) => Promise + } + ) { + this.l1 = new Cache(l1Options) + this.l2 = l2 + } + + async get(key: string): Promise { + // Tentar L1 primeiro + const l1Value = this.l1.get(key) + if (l1Value !== undefined) { + return l1Value + } + + // Tentar L2 se disponível + if (this.l2) { + const l2Value = await this.l2.get(key) + if (l2Value !== undefined) { + // Promover para L1 + this.l1.set(key, l2Value) + return l2Value + } + } + + return undefined + } + + async set(key: string, value: V, ttl?: number): Promise { + this.l1.set(key, value, ttl) + + if (this.l2) { + await this.l2.set(key, value, ttl) + } + } + + async delete(key: string): Promise { + const l1Result = this.l1.delete(key) + let l2Result = false + + if (this.l2) { + l2Result = await this.l2.delete(key) + } + + return l1Result || l2Result + } + + getL1(): Cache { + return this.l1 + } +} + +/** + * Decorator para cachear resultado de método + */ +export function cached(options: CacheOptions & { keyGenerator?: (...args: unknown[]) => string } = {}) { + const cache = new Cache(options) + const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args)) + + return function ( + _target: unknown, + propertyKey: string, + descriptor: TypedPropertyDescriptor<(...args: unknown[]) => V | Promise> + ) { + const originalMethod = descriptor.value + if (!originalMethod) return descriptor + + descriptor.value = async function (...args: unknown[]): Promise { + 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 para função com cache + */ +export function withCache unknown>( + fn: T, + options: CacheOptions> & { keyGenerator?: (...args: Parameters) => string } = {} +): T { + const cache = new Cache>(options) + const keyGenerator = options.keyGenerator ?? ((...args) => JSON.stringify(args)) + + return ((...args: Parameters): ReturnType => { + const key = keyGenerator(...args) + + const cachedValue = cache.get(key) + if (cachedValue !== undefined) { + return cachedValue as ReturnType + } + + const result = fn(...args) as ReturnType + + if (result instanceof Promise) { + return result.then((value) => { + cache.set(key, value as ReturnType) + return value + }) as ReturnType + } + + cache.set(key, result) + return result + }) as T +} + +/** + * Cache global singleton por namespace + */ +const globalCaches: Map> = new Map() + +export function getGlobalCache(namespace: string, options?: CacheOptions): Cache { + if (!globalCaches.has(namespace)) { + globalCaches.set(namespace, new Cache({ ...options, namespace })) + } + return globalCaches.get(namespace) as Cache +} + +export function clearGlobalCaches(): void { + for (const cache of globalCaches.values()) { + cache.clear() + } + globalCaches.clear() +} + +export default Cache diff --git a/src/Utils/circuit-breaker.ts b/src/Utils/circuit-breaker.ts new file mode 100644 index 00000000..f2caa7be --- /dev/null +++ b/src/Utils/circuit-breaker.ts @@ -0,0 +1,532 @@ +/** + * @fileoverview Proteção contra falhas em cascata - Circuit Breaker + * @module Utils/circuit-breaker + * + * Fornece: + * - Estados: Closed, Open, Half-Open + * - Thresholds configuráveis + * - Recuperação automática + * - Callbacks de estado + * - Integração com métricas + * - Fallback handlers + */ + +import { EventEmitter } from 'events' +import { metrics } from './prometheus-metrics.js' + +/** + * Estados do Circuit Breaker + */ +export type CircuitState = 'closed' | 'open' | 'half-open' + +/** + * Opções de configuração do Circuit Breaker + */ +export interface CircuitBreakerOptions { + /** Nome do circuit breaker (para métricas) */ + name: string + /** Número de falhas para abrir o circuito */ + failureThreshold?: number + /** Número de sucessos para fechar o circuito (em half-open) */ + successThreshold?: number + /** Tempo em ms para tentar half-open após open */ + resetTimeout?: number + /** Timeout para operações em ms */ + timeout?: number + /** Função para determinar se erro deve contar como falha */ + isFailure?: (error: Error) => boolean + /** Coletar métricas */ + collectMetrics?: boolean + /** Função de fallback quando circuito está aberto */ + fallback?: () => T | Promise + /** Callback quando estado muda */ + onStateChange?: (from: CircuitState, to: CircuitState) => void + /** Callback em falha */ + onFailure?: (error: Error) => void + /** Callback em sucesso */ + onSuccess?: () => void + /** Callback quando circuito abre */ + onOpen?: () => void + /** Callback quando circuito fecha */ + onClose?: () => void +} + +/** + * Estatísticas do Circuit Breaker + */ +export interface CircuitBreakerStats { + state: CircuitState + failures: number + successes: number + totalCalls: number + totalFailures: number + totalSuccesses: number + lastFailureTime?: number + lastSuccessTime?: number + lastStateChange?: number + isOpen: boolean + isClosed: boolean + isHalfOpen: boolean +} + +/** + * Erro lançado quando circuito está aberto + */ +export class CircuitOpenError extends Error { + constructor( + public readonly circuitName: string, + public readonly state: CircuitState + ) { + super(`Circuit breaker "${circuitName}" is ${state}`) + this.name = 'CircuitOpenError' + } +} + +/** + * Erro de 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' + } +} + +/** + * Classe principal do Circuit Breaker + */ +export class CircuitBreaker extends EventEmitter { + private state: CircuitState = 'closed' + private failures = 0 + private successes = 0 + private totalCalls = 0 + private totalFailures = 0 + private totalSuccesses = 0 + private lastFailureTime?: number + private lastSuccessTime?: number + private lastStateChange?: number + private resetTimer?: ReturnType + private options: Required + + constructor(options: CircuitBreakerOptions) { + super() + + this.options = { + name: options.name, + failureThreshold: options.failureThreshold ?? 5, + successThreshold: options.successThreshold ?? 2, + resetTimeout: options.resetTimeout ?? 30000, + timeout: options.timeout ?? 10000, + isFailure: options.isFailure ?? (() => 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 ?? (() => {}), + } + + this.lastStateChange = Date.now() + } + + /** + * Executa operação protegida pelo circuit breaker + */ + async execute(operation: () => T | Promise): Promise { + this.totalCalls++ + + // Verificar estado + if (this.state === 'open') { + if (this.options.collectMetrics) { + metrics.errors.inc({ category: 'circuit_breaker', code: 'open' }) + } + + return this.options.fallback() as T + } + + // Executar com timeout + try { + const result = await this.executeWithTimeout(operation) + this.recordSuccess() + return result + } catch (error) { + this.recordFailure(error as Error) + throw error + } + } + + /** + * Executa operação síncrona protegida + */ + executeSync(operation: () => T): T { + this.totalCalls++ + + if (this.state === 'open') { + if (this.options.collectMetrics) { + metrics.errors.inc({ category: 'circuit_breaker', code: 'open' }) + } + + return this.options.fallback() as T + } + + try { + const result = operation() + this.recordSuccess() + return result + } catch (error) { + this.recordFailure(error as Error) + throw error + } + } + + /** + * Executa com timeout + */ + private async executeWithTimeout(operation: () => T | Promise): Promise { + return new Promise((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) + }) + }) + } + + /** + * Registra sucesso + */ + private recordSuccess(): void { + this.totalSuccesses++ + this.lastSuccessTime = Date.now() + this.failures = 0 + + if (this.options.collectMetrics) { + metrics.socketEvents.inc({ event: 'circuit_success' }) + } + + this.options.onSuccess() + this.emit('success') + + if (this.state === 'half-open') { + this.successes++ + + if (this.successes >= this.options.successThreshold) { + this.transitionTo('closed') + } + } + } + + /** + * Registra falha + */ + private recordFailure(error: Error): void { + // Verificar se erro deve contar como falha + if (!this.options.isFailure(error)) { + return + } + + this.totalFailures++ + this.lastFailureTime = Date.now() + this.failures++ + this.successes = 0 + + if (this.options.collectMetrics) { + metrics.errors.inc({ category: 'circuit_breaker', code: 'failure' }) + } + + this.options.onFailure(error) + this.emit('failure', error) + + if (this.state === 'half-open') { + this.transitionTo('open') + } else if (this.state === 'closed' && this.failures >= this.options.failureThreshold) { + this.transitionTo('open') + } + } + + /** + * Transiciona para novo estado + */ + private transitionTo(newState: CircuitState): void { + const oldState = this.state + + if (oldState === newState) { + return + } + + this.state = newState + this.lastStateChange = Date.now() + + // Limpar timer existente + if (this.resetTimer) { + clearTimeout(this.resetTimer) + this.resetTimer = undefined + } + + // Resetar contadores baseado no novo estado + if (newState === 'closed') { + this.failures = 0 + this.successes = 0 + this.options.onClose() + this.emit('close') + } else if (newState === 'open') { + this.successes = 0 + this.options.onOpen() + this.emit('open') + + // Agendar tentativa de half-open + this.resetTimer = setTimeout(() => { + this.transitionTo('half-open') + }, this.options.resetTimeout) + } else if (newState === 'half-open') { + this.successes = 0 + this.failures = 0 + this.emit('half-open') + } + + this.options.onStateChange(oldState, newState) + this.emit('state-change', { from: oldState, to: newState }) + } + + /** + * Força abertura do circuito + */ + trip(): void { + this.transitionTo('open') + } + + /** + * Força fechamento do circuito + */ + reset(): void { + this.transitionTo('closed') + } + + /** + * Retorna estado atual + */ + getState(): CircuitState { + return this.state + } + + /** + * Verifica se circuito está aberto + */ + isOpen(): boolean { + return this.state === 'open' + } + + /** + * Verifica se circuito está fechado + */ + isClosed(): boolean { + return this.state === 'closed' + } + + /** + * Verifica se circuito está half-open + */ + isHalfOpen(): boolean { + return this.state === 'half-open' + } + + /** + * Retorna estatísticas + */ + getStats(): CircuitBreakerStats { + return { + state: this.state, + failures: this.failures, + successes: this.successes, + totalCalls: this.totalCalls, + totalFailures: this.totalFailures, + totalSuccesses: this.totalSuccesses, + lastFailureTime: this.lastFailureTime, + lastSuccessTime: this.lastSuccessTime, + lastStateChange: this.lastStateChange, + isOpen: this.isOpen(), + isClosed: this.isClosed(), + isHalfOpen: this.isHalfOpen(), + } + } + + /** + * Retorna nome do circuit breaker + */ + getName(): string { + return this.options.name + } + + /** + * Destroy e limpa recursos + */ + destroy(): void { + if (this.resetTimer) { + clearTimeout(this.resetTimer) + } + this.removeAllListeners() + } +} + +/** + * Factory para criar circuit breaker + */ +export function createCircuitBreaker(options: CircuitBreakerOptions): CircuitBreaker { + return new CircuitBreaker(options) +} + +/** + * Registry de circuit breakers + */ +export class CircuitBreakerRegistry { + private breakers: Map = new Map() + + /** + * Obtém ou cria circuit breaker + */ + get(name: string, options?: Omit): CircuitBreaker { + if (!this.breakers.has(name)) { + const breaker = new CircuitBreaker({ ...options, name }) + this.breakers.set(name, breaker) + } + return this.breakers.get(name)! + } + + /** + * Verifica se circuit breaker existe + */ + has(name: string): boolean { + return this.breakers.has(name) + } + + /** + * Remove circuit breaker + */ + remove(name: string): boolean { + const breaker = this.breakers.get(name) + if (breaker) { + breaker.destroy() + return this.breakers.delete(name) + } + return false + } + + /** + * Retorna todos os circuit breakers + */ + getAll(): Map { + return new Map(this.breakers) + } + + /** + * Retorna estatísticas de todos os circuit breakers + */ + getAllStats(): Record { + const stats: Record = {} + for (const [name, breaker] of this.breakers) { + stats[name] = breaker.getStats() + } + return stats + } + + /** + * Reseta todos os circuit breakers + */ + resetAll(): void { + for (const breaker of this.breakers.values()) { + breaker.reset() + } + } + + /** + * Destroy todos os circuit breakers + */ + destroyAll(): void { + for (const breaker of this.breakers.values()) { + breaker.destroy() + } + this.breakers.clear() + } +} + +/** + * Registry global + */ +export const globalCircuitRegistry = new CircuitBreakerRegistry() + +/** + * Decorator para proteger método com circuit breaker + */ +export function circuitBreaker(options: Omit & { 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 { + return breaker.execute(() => originalMethod.apply(this, args)) + } + + return descriptor + } +} + +/** + * Wrapper para função com circuit breaker + */ +export function withCircuitBreaker unknown>( + fn: T, + options: CircuitBreakerOptions +): T { + const breaker = new CircuitBreaker(options) + + return (async (...args: Parameters): Promise> => { + return breaker.execute(() => fn(...args)) as Promise> + }) as unknown as T +} + +/** + * Verifica saúde de todos os circuit breakers + */ +export function getCircuitHealth(): { + healthy: boolean + openCircuits: string[] + stats: Record +} { + 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, + } +} + +export default CircuitBreaker diff --git a/src/Utils/index.ts b/src/Utils/index.ts index 852854d4..1780cacd 100644 --- a/src/Utils/index.ts +++ b/src/Utils/index.ts @@ -16,3 +16,22 @@ export * from './event-buffer' export * from './process-message' export * from './message-retry-manager' export * from './browser-utils' + +// === Novos Utilitários de Observabilidade e Resiliência === + +// Logging estruturado +export * from './structured-logger' +export * from './logger-adapter' +export * from './baileys-logger' + +// Observabilidade e rastreamento +export * from './trace-context' +export * from './prometheus-metrics' + +// Resiliência e performance +export * from './cache-utils' +export * from './circuit-breaker' +export * from './retry-utils' + +// Event streaming +export * from './baileys-event-stream' diff --git a/src/Utils/logger-adapter.ts b/src/Utils/logger-adapter.ts new file mode 100644 index 00000000..141d7188 --- /dev/null +++ b/src/Utils/logger-adapter.ts @@ -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 + /** Transformador de contexto */ + contextTransformer?: (context: Record) => Record + /** 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 = { + 10: 'trace', + 20: 'debug', + 30: 'info', + 40: 'warn', + 50: 'error', + 60: 'fatal', +} + +/** + * Mapeamento reverso para Pino + */ +const STRUCTURED_TO_PINO_LEVEL: Record = { + 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 = {}) { + 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): 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[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 = { + trace: 'trace', + debug: 'debug', + info: 'info', + warn: 'warn', + error: 'error', + fatal: 'fatal', + silent: 'silent', + } + return levelMap[pinoLevel] || 'info' + } + + child(obj: Record): 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 +): 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 + + return { + level: (logObj.level as string) || 'info', + child: (obj: Record) => { + if (typeof logObj.child === 'function') { + return normalizeLogger((logObj.child as (obj: Record) => 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 + + 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, + 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)[level] === 'function') { + ;(console as Record 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): 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 diff --git a/src/Utils/prometheus-metrics.ts b/src/Utils/prometheus-metrics.ts new file mode 100644 index 00000000..85eb75a5 --- /dev/null +++ b/src/Utils/prometheus-metrics.ts @@ -0,0 +1,784 @@ +/** + * @fileoverview Exposição de métricas no formato Prometheus + * @module Utils/prometheus-metrics + * + * Fornece: + * - Counters para contagem de eventos + * - Gauges para valores instantâneos + * - Histograms para distribuição de valores + * - Summaries para percentis + * - Endpoint /metrics pronto para scraping + * - Labels dinâmicas + * - Integração com Baileys events + * + * Nota: Funciona de forma standalone sem prom-client, + * mas pode ser integrado com prom-client se disponível. + */ + +/** + * Tipo de métrica + */ +export type MetricType = 'counter' | 'gauge' | 'histogram' | 'summary' + +/** + * Labels para métricas + */ +export type Labels = Record + +/** + * Buckets padrão para histogramas (em ms) + */ +export const DEFAULT_BUCKETS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000] + +/** + * Percentis padrão para summaries + */ +export const DEFAULT_PERCENTILES = [0.5, 0.9, 0.95, 0.99] + +/** + * Interface base para métricas + */ +export interface BaseMetric { + name: string + help: string + type: MetricType + labelNames: string[] +} + +/** + * Valor de uma métrica com labels + */ +export interface MetricValue { + labels: Labels + value: number + timestamp?: number +} + +/** + * Valores de histograma + */ +export interface HistogramValue { + labels: Labels + buckets: Map + sum: number + count: number +} + +/** + * Valores de summary + */ +export interface SummaryValue { + labels: Labels + values: number[] + sum: number + count: number +} + +/** + * Classe Counter - incrementa monotonicamente + */ +export class Counter implements BaseMetric { + readonly type = 'counter' as const + private values: Map = new Map() + + constructor( + public name: string, + public help: string, + public labelNames: string[] = [] + ) {} + + /** + * Incrementa o 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(), + }) + } + } + + /** + * Retorna valor atual + */ + get(labels: Labels = {}): number { + const key = this.labelsToKey(labels) + return this.values.get(key)?.value ?? 0 + } + + /** + * Reseta o counter + */ + reset(labels?: Labels): void { + if (labels) { + const key = this.labelsToKey(labels) + this.values.delete(key) + } else { + this.values.clear() + } + } + + /** + * Retorna todos os valores + */ + getValues(): MetricValue[] { + return Array.from(this.values.values()) + } + + private labelsToKey(labels: Labels): string { + return JSON.stringify(labels) + } + + /** + * Cria versão com labels pré-definidas + */ + labels(labels: Labels): { inc: (value?: number) => void } { + return { + inc: (value?: number) => this.inc(labels, value), + } + } +} + +/** + * Classe Gauge - valor que pode aumentar e diminuir + */ +export class Gauge implements BaseMetric { + readonly type = 'gauge' as const + private values: Map = new Map() + + constructor( + public name: string, + public help: string, + public labelNames: string[] = [] + ) {} + + /** + * Define valor + */ + 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(), + }) + } + + /** + * Incrementa valor + */ + 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) + } + + /** + * Decrementa valor + */ + 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) + } + + /** + * Define para timestamp atual + */ + setToCurrentTime(labels: Labels = {}): void { + this.set(labels, Date.now() / 1000) + } + + /** + * Retorna valor atual + */ + get(labels: Labels = {}): number { + const key = this.labelsToKey(labels) + return this.values.get(key)?.value ?? 0 + } + + /** + * Reseta o gauge + */ + reset(labels?: Labels): void { + if (labels) { + const key = this.labelsToKey(labels) + this.values.delete(key) + } else { + this.values.clear() + } + } + + /** + * Retorna todos os valores + */ + getValues(): MetricValue[] { + return Array.from(this.values.values()) + } + + private labelsToKey(labels: Labels): string { + return JSON.stringify(labels) + } + + /** + * Cria versão com labels pré-definidas + */ + 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 - retorna função para parar e registrar duração + */ + startTimer(labels: Labels = {}): () => number { + const start = process.hrtime.bigint() + return () => { + const duration = Number(process.hrtime.bigint() - start) / 1_000_000_000 // segundos + this.set(labels, duration) + return duration + } + } +} + +/** + * Classe Histogram - distribuição de valores em buckets + */ +export class Histogram implements BaseMetric { + readonly type = 'histogram' as const + private values: Map = 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) + } + + /** + * Observa um valor + */ + 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) + } + + // Incrementar buckets apropriados + 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 + } + } + + /** + * Retorna valores do histogram + */ + get(labels: Labels = {}): HistogramValue | undefined { + const key = this.labelsToKey(labels) + return this.values.get(key) + } + + /** + * Reseta o histogram + */ + reset(labels?: Labels): void { + if (labels) { + const key = this.labelsToKey(labels) + this.values.delete(key) + } else { + this.values.clear() + } + } + + /** + * Retorna todos os valores + */ + getValues(): HistogramValue[] { + return Array.from(this.values.values()) + } + + /** + * Retorna buckets configurados + */ + getBuckets(): number[] { + return [...this.buckets] + } + + private labelsToKey(labels: Labels): string { + return JSON.stringify(labels) + } + + /** + * Cria versão com labels pré-definidas + */ + labels(labels: Labels): { + observe: (value: number) => void + startTimer: () => () => number + } { + return { + observe: (value: number) => this.observe(labels, value), + startTimer: () => this.startTimer(labels), + } + } +} + +/** + * Classe Summary - percentis de valores + */ +export class Summary implements BaseMetric { + readonly type = 'summary' as const + private values: Map = 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 + } + + /** + * Observa um valor + */ + 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++ + + // Limitar tamanho + 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 + } + } + + /** + * Calcula percentil + */ + 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)] + } + + /** + * Retorna valores do summary + */ + get(labels: Labels = {}): SummaryValue | undefined { + const key = this.labelsToKey(labels) + return this.values.get(key) + } + + /** + * Reseta o summary + */ + reset(labels?: Labels): void { + if (labels) { + const key = this.labelsToKey(labels) + this.values.delete(key) + } else { + this.values.clear() + } + } + + /** + * Retorna todos os valores + */ + getValues(): SummaryValue[] { + return Array.from(this.values.values()) + } + + /** + * Retorna percentis configurados + */ + getPercentiles(): number[] { + return [...this.percentiles] + } + + private labelsToKey(labels: Labels): string { + return JSON.stringify(labels) + } + + /** + * Cria versão com labels pré-definidas + */ + labels(labels: Labels): { + observe: (value: number) => void + startTimer: () => () => number + } { + return { + observe: (value: number) => this.observe(labels, value), + startTimer: () => this.startTimer(labels), + } + } +} + +/** + * Registry de métricas + */ +export class MetricsRegistry { + private metrics: Map = new Map() + private prefix: string + private defaultLabels: Labels + + constructor(options: { prefix?: string; defaultLabels?: Labels } = {}) { + this.prefix = options.prefix || '' + this.defaultLabels = options.defaultLabels || {} + } + + /** + * Registra uma métrica + */ + register(metric: T): T { + const fullName = this.prefix ? `${this.prefix}_${metric.name}` : metric.name + this.metrics.set(fullName, metric) + return metric + } + + /** + * Obtém uma métrica + */ + get(name: string): Counter | Gauge | Histogram | Summary | undefined { + const fullName = this.prefix ? `${this.prefix}_${name}` : name + return this.metrics.get(fullName) + } + + /** + * Remove uma métrica + */ + remove(name: string): boolean { + const fullName = this.prefix ? `${this.prefix}_${name}` : name + return this.metrics.delete(fullName) + } + + /** + * Reseta todas as métricas + */ + resetAll(): void { + for (const metric of this.metrics.values()) { + metric.reset() + } + } + + /** + * Retorna métricas no formato Prometheus + */ + async metrics(): Promise { + 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') + } + + /** + * Retorna content type para 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') + } +} + +// === Métricas Padrão do Baileys === + +/** + * Registry global para métricas do Baileys + */ +export const baileysMetrics = new MetricsRegistry({ prefix: 'baileys' }) + +/** + * Métricas pré-definidas para Baileys + */ +export const metrics = { + // Conexão + connectionAttempts: baileysMetrics.register( + new Counter('connection_attempts_total', 'Total de tentativas de conexão', ['status']) + ), + connectionState: baileysMetrics.register( + new Gauge('connection_state', 'Estado atual da conexão (0=desconectado, 1=conectado)', ['instance']) + ), + connectionDuration: baileysMetrics.register( + new Gauge('connection_duration_seconds', 'Duração da conexão atual em segundos', ['instance']) + ), + + // Mensagens + messagesSent: baileysMetrics.register( + new Counter('messages_sent_total', 'Total de mensagens enviadas', ['type']) + ), + messagesReceived: baileysMetrics.register( + new Counter('messages_received_total', 'Total de mensagens recebidas', ['type']) + ), + messageLatency: baileysMetrics.register( + new Histogram('message_latency_ms', 'Latência de envio de mensagem em ms', ['type'], [10, 50, 100, 250, 500, 1000, 2500, 5000]) + ), + + // Mídia + mediaUploads: baileysMetrics.register( + new Counter('media_uploads_total', 'Total de uploads de mídia', ['type', 'status']) + ), + mediaDownloads: baileysMetrics.register( + new Counter('media_downloads_total', 'Total de downloads de mídia', ['type', 'status']) + ), + mediaSize: baileysMetrics.register( + new Histogram('media_size_bytes', 'Tamanho de mídia em bytes', ['type', 'direction'], [1024, 10240, 102400, 1048576, 10485760]) + ), + + // Erros + errors: baileysMetrics.register( + new Counter('errors_total', 'Total de erros', ['category', 'code']) + ), + + // Retries + retries: baileysMetrics.register( + new Counter('retries_total', 'Total de retentativas', ['operation']) + ), + retryLatency: baileysMetrics.register( + new Histogram('retry_latency_ms', 'Latência de retentativas em ms', ['operation']) + ), + + // Socket + socketEvents: baileysMetrics.register( + new Counter('socket_events_total', 'Total de eventos de socket', ['event']) + ), + socketLatency: baileysMetrics.register( + new Histogram('socket_latency_ms', 'Latência de operações de socket em ms', ['operation']) + ), + + // Criptografia + encryptionOperations: baileysMetrics.register( + new Counter('encryption_operations_total', 'Total de operações de criptografia', ['operation']) + ), + + // Cache + cacheHits: baileysMetrics.register(new Counter('cache_hits_total', 'Total de cache hits', ['cache'])), + cacheMisses: baileysMetrics.register(new Counter('cache_misses_total', 'Total de cache misses', ['cache'])), + cacheSize: baileysMetrics.register(new Gauge('cache_size', 'Tamanho atual do cache', ['cache'])), +} + +/** + * Helper para criar endpoint HTTP de métricas + */ +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 diff --git a/src/Utils/retry-utils.ts b/src/Utils/retry-utils.ts new file mode 100644 index 00000000..7410e5dd --- /dev/null +++ b/src/Utils/retry-utils.ts @@ -0,0 +1,635 @@ +/** + * @fileoverview Lógica de retry inteligente + * @module Utils/retry-utils + * + * Fornece: + * - Exponential backoff + * - Jitter para evitar thundering herd + * - Max attempts configurável + * - Predicates de retry customizáveis + * - Integração com circuit breaker + * - Hooks de eventos + * - Cancelamento + */ + +import { EventEmitter } from 'events' +import { metrics } from './prometheus-metrics.js' +import type { CircuitBreaker } from './circuit-breaker.js' + +/** + * Estratégias de backoff + */ +export type BackoffStrategy = 'exponential' | 'linear' | 'constant' | 'fibonacci' + +/** + * Opções de configuração de retry + */ +export interface RetryOptions { + /** Número máximo de tentativas (default: 3) */ + maxAttempts?: number + /** Delay base em ms (default: 1000) */ + baseDelay?: number + /** Delay máximo em ms (default: 30000) */ + maxDelay?: number + /** Estratégia de backoff (default: exponential) */ + backoffStrategy?: BackoffStrategy + /** Multiplicador para exponential backoff (default: 2) */ + backoffMultiplier?: number + /** Percentual de jitter (0-1, default: 0.1) */ + jitter?: number + /** Função para determinar se deve retry */ + shouldRetry?: (error: Error, attempt: number) => boolean | Promise + /** Timeout por tentativa em ms */ + timeout?: number + /** Nome da operação para métricas */ + operationName?: string + /** Coletar métricas */ + collectMetrics?: boolean + /** Circuit breaker para integração */ + circuitBreaker?: CircuitBreaker + /** Callback antes de cada retry */ + onRetry?: (error: Error, attempt: number, delay: number) => void | Promise + /** Callback em sucesso */ + onSuccess?: (result: unknown, attempt: number) => void + /** Callback em falha final */ + onFailure?: (error: Error, attempts: number) => void + /** Signal para cancelamento */ + abortSignal?: AbortSignal +} + +/** + * Resultado de operação com retry + */ +export interface RetryResult { + success: boolean + result?: T + error?: Error + attempts: number + totalDuration: number + lastAttemptDuration: number +} + +/** + * Contexto de retry + */ +export interface RetryContext { + attempt: number + maxAttempts: number + lastError?: Error + startTime: number + aborted: boolean +} + +/** + * Erro de retry esgotado + */ +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' + } +} + +/** + * Erro de abort + */ +export class RetryAbortedError extends Error { + constructor(public readonly attempt: number) { + super(`Retry aborted at attempt ${attempt}`) + this.name = 'RetryAbortedError' + } +} + +/** + * Calcula delay com base na estratégia + */ +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 + } + + // Aplicar cap de delay máximo + delay = Math.min(delay, maxDelay) + + // Aplicar jitter + if (jitter > 0) { + const jitterAmount = delay * jitter + delay = delay + (Math.random() * 2 - 1) * jitterAmount + } + + return Math.max(0, Math.round(delay)) +} + +/** + * Calcula número de Fibonacci + */ +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 com suporte a abort + */ +async function sleep(ms: number, signal?: AbortSignal): Promise { + 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 }) + } + }) +} + +/** + * Executa operação com timeout + */ +async function executeWithTimeout( + operation: () => Promise, + timeout: number, + signal?: AbortSignal +): Promise { + return new Promise((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) + } + }) + }) +} + +/** + * Função principal de retry + */ +export async function retry( + operation: (context: RetryContext) => T | Promise, + options: RetryOptions = {} +): Promise { + 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 + + // Verificar abort inicial + if (config.abortSignal?.aborted) { + throw new RetryAbortedError(0) + } + + for (let attempt = 1; attempt <= config.maxAttempts; attempt++) { + context.attempt = attempt + + // Verificar abort + if (config.abortSignal?.aborted) { + context.aborted = true + throw new RetryAbortedError(attempt) + } + + // Verificar circuit breaker + if (config.circuitBreaker?.isOpen()) { + throw new Error(`Circuit breaker "${config.circuitBreaker.getName()}" is open`) + } + + try { + // Executar operação + let result: T + + if (config.timeout) { + result = await executeWithTimeout( + () => Promise.resolve(operation(context)), + config.timeout, + config.abortSignal + ) + } else { + result = await operation(context) + } + + // Sucesso + if (config.collectMetrics) { + metrics.retries.inc({ operation: config.operationName }) + } + + config.onSuccess(result, attempt) + return result + } catch (error) { + lastError = error as Error + context.lastError = lastError + + // Verificar se deve retry + const shouldRetry = await config.shouldRetry(lastError, attempt) + + if (!shouldRetry || attempt >= config.maxAttempts) { + // Falha final + if (config.collectMetrics) { + metrics.errors.inc({ category: 'retry', code: 'exhausted' }) + } + + config.onFailure(lastError, attempt) + + throw new RetryExhaustedError(lastError, attempt, config.operationName) + } + + // Calcular delay + const delay = calculateDelay( + attempt, + config.baseDelay, + config.maxDelay, + config.backoffStrategy, + config.backoffMultiplier, + config.jitter + ) + + // Callback de retry + await config.onRetry(lastError, attempt, delay) + + if (config.collectMetrics) { + metrics.retryLatency.observe({ operation: config.operationName }, delay) + } + + // Aguardar delay + await sleep(delay, config.abortSignal) + } + } + + // Nunca deve chegar aqui, mas TypeScript precisa + throw new RetryExhaustedError(lastError || new Error('Unknown error'), config.maxAttempts, config.operationName) +} + +/** + * Retry com resultado detalhado + */ +export async function retryWithResult( + operation: (context: RetryContext) => T | Promise, + options: RetryOptions = {} +): Promise> { + 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 para criar função de retry configurada + */ +export function createRetrier(defaultOptions: RetryOptions = {}) { + return ( + operation: (context: RetryContext) => T | Promise, + options?: RetryOptions + ): Promise => { + return retry(operation, { ...defaultOptions, ...options }) + } +} + +/** + * Decorator para adicionar retry a método + */ +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 { + return retry( + () => originalMethod.apply(this, args), + { ...options, operationName: options.operationName || propertyKey } + ) + } + + return descriptor + } +} + +/** + * Wrapper para função com retry + */ +export function retryable unknown>( + fn: T, + options: RetryOptions = {} +): (...args: Parameters) => Promise> { + return async (...args: Parameters): Promise> => { + return retry(() => fn(...args), options) as Promise> + } +} + +/** + * Classe para gerenciar retries com estado + */ +export class RetryManager extends EventEmitter { + private activeRetries: Map void; context: RetryContext }> = new Map() + private defaultOptions: RetryOptions + + constructor(defaultOptions: RetryOptions = {}) { + super() + this.defaultOptions = defaultOptions + } + + /** + * Executa operação com retry + */ + async execute( + id: string, + operation: (context: RetryContext) => T | Promise, + options?: RetryOptions + ): Promise { + // Cancelar retry anterior com mesmo 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) + } + } + + /** + * Cancela retry em andamento + */ + 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 + } + + /** + * Cancela todos os retries + */ + cancelAll(): void { + for (const [id, active] of this.activeRetries) { + active.cancel() + this.emit('cancelled', { id }) + } + this.activeRetries.clear() + } + + /** + * Verifica se há retry ativo + */ + isActive(id: string): boolean { + return this.activeRetries.has(id) + } + + /** + * Retorna contexto de retry ativo + */ + getContext(id: string): RetryContext | undefined { + return this.activeRetries.get(id)?.context + } + + /** + * Retorna IDs de retries ativos + */ + getActiveIds(): string[] { + return Array.from(this.activeRetries.keys()) + } +} + +/** + * Predicates comuns para shouldRetry + */ +export const retryPredicates = { + /** Sempre retry (até max attempts) */ + always: () => true, + + /** Nunca retry */ + never: () => false, + + /** Retry apenas em erros de rede */ + 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 apenas em erros específicos */ + onErrorCodes: + (codes: string[]) => + (error: Error): boolean => { + return codes.some((code) => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code) + }, + + /** Retry exceto em erros específicos */ + exceptErrorCodes: + (codes: string[]) => + (error: Error): boolean => { + return !codes.some((code) => error.message.includes(code) || (error as NodeJS.ErrnoException).code === code) + }, + + /** Retry em erros HTTP 5xx ou 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') + ) + }, + + /** Combina múltiplos predicates com OR */ + or: + (...predicates: Array<(error: Error, attempt: number) => boolean>) => + (error: Error, attempt: number): boolean => { + return predicates.some((p) => p(error, attempt)) + }, + + /** Combina múltiplos predicates com AND */ + and: + (...predicates: Array<(error: Error, attempt: number) => boolean>) => + (error: Error, attempt: number): boolean => { + return predicates.every((p) => p(error, attempt)) + }, +} + +/** + * Configurações pré-definidas de retry + */ +export const retryConfigs = { + /** Retry agressivo (muitas tentativas, delays curtos) */ + aggressive: { + maxAttempts: 10, + baseDelay: 100, + maxDelay: 5000, + backoffStrategy: 'exponential' as const, + jitter: 0.2, + }, + + /** Retry conservador (poucas tentativas, delays longos) */ + conservative: { + maxAttempts: 3, + baseDelay: 2000, + maxDelay: 60000, + backoffStrategy: 'exponential' as const, + jitter: 0.1, + }, + + /** Retry rápido (para operações curtas) */ + fast: { + maxAttempts: 5, + baseDelay: 50, + maxDelay: 1000, + backoffStrategy: 'linear' as const, + jitter: 0.05, + }, + + /** Retry para operações de rede */ + network: { + maxAttempts: 5, + baseDelay: 1000, + maxDelay: 30000, + backoffStrategy: 'exponential' as const, + jitter: 0.1, + shouldRetry: retryPredicates.onNetworkError, + }, +} + +export default retry diff --git a/src/Utils/structured-logger.ts b/src/Utils/structured-logger.ts new file mode 100644 index 00000000..be2167c8 --- /dev/null +++ b/src/Utils/structured-logger.ts @@ -0,0 +1,482 @@ +/** + * @fileoverview Sistema de logging estruturado para InfiniteAPI + * @module Utils/structured-logger + * + * Fornece: + * - Níveis de log configuráveis (trace, debug, info, warn, error, fatal) + * - Formatação JSON para análise + * - Contexto hierárquico com child loggers + * - Integração com sistemas externos (hooks) + * - Suporte a métricas de logging + * - Sanitização de dados sensíveis + */ + +import type { ILogger } from './logger.js' + +/** + * Níveis de log disponíveis (ordenados por severidade) + */ +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent' + +/** + * Valores numéricos para cada nível de log + */ +export const LOG_LEVEL_VALUES: Record = { + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, + fatal: 60, + silent: 100, +} + +/** + * Configuração do logger estruturado + */ +export interface StructuredLoggerConfig { + /** Nível mínimo de log a ser registrado */ + level: LogLevel + /** Nome do serviço/componente */ + name?: string + /** Contexto adicional a ser incluído em todos os logs */ + context?: Record + /** Se deve formatar como JSON (true) ou texto legível (false) */ + jsonFormat?: boolean + /** Campos a serem sanitizados (senhas, tokens, etc.) */ + redactFields?: string[] + /** Hook para enviar logs para sistemas externos */ + externalHook?: (entry: LogEntry) => void | Promise + /** Se deve incluir stack trace em erros */ + includeStackTrace?: boolean + /** Timezone para timestamps (default: UTC) */ + timezone?: string +} + +/** + * Entrada de log estruturada + */ +export interface LogEntry { + /** Timestamp ISO 8601 */ + timestamp: string + /** Nível do log */ + level: LogLevel + /** Valor numérico do nível */ + levelValue: number + /** Mensagem principal */ + message: string + /** Nome do logger/componente */ + name?: string + /** Contexto adicional */ + context?: Record + /** Dados do objeto logado */ + data?: Record + /** Stack trace (para erros) */ + stack?: string + /** ID de correlação para rastreamento */ + correlationId?: string + /** Duração de operação em ms (se aplicável) */ + durationMs?: number +} + +/** + * Métricas de logging + */ +export interface LoggerMetrics { + totalLogs: number + logsByLevel: Record + errorsCount: number + lastLogTimestamp?: string +} + +/** + * Campos padrão a serem sanitizados + */ +const DEFAULT_REDACT_FIELDS = [ + 'password', + 'passwd', + 'secret', + 'token', + 'accessToken', + 'refreshToken', + 'apiKey', + 'api_key', + 'authorization', + 'auth', + 'credentials', + 'privateKey', + 'private_key', +] + +/** + * Classe principal do Logger Estruturado + */ +export class StructuredLogger implements ILogger { + private config: Required + private metrics: LoggerMetrics + private childContext: Record = {} + + 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, + } + } + + /** + * Getter para o nível atual do logger (compatibilidade com ILogger) + */ + get level(): string { + return this.config.level + } + + /** + * Setter para o nível do logger + */ + set level(newLevel: string) { + if (newLevel in LOG_LEVEL_VALUES) { + this.config.level = newLevel as LogLevel + } + } + + /** + * Cria um logger filho com contexto adicional + */ + child(obj: Record): StructuredLogger { + const childLogger = new StructuredLogger({ + ...this.config, + context: { ...this.config.context, ...this.childContext, ...obj }, + }) + childLogger.childContext = { ...this.childContext, ...obj } + return childLogger + } + + /** + * Verifica se o nível de log está habilitado + */ + isLevelEnabled(level: LogLevel): boolean { + return LOG_LEVEL_VALUES[level] >= LOG_LEVEL_VALUES[this.config.level] + } + + /** + * Método principal de logging + */ + private log(level: LogLevel, obj: unknown, msg?: string): void { + if (!this.isLevelEnabled(level)) { + return + } + + const entry = this.createLogEntry(level, obj, msg) + + // Atualizar métricas + this.updateMetrics(level) + + // Output + this.output(entry) + + // Hook externo (async, não bloqueia) + if (this.config.externalHook) { + Promise.resolve(this.config.externalHook(entry)).catch(() => { + // Silently ignore hook errors + }) + } + } + + /** + * Cria uma entrada de log estruturada + */ + private createLogEntry(level: LogLevel, obj: unknown, msg?: string): LogEntry { + const timestamp = new Date().toISOString() + let message = msg || '' + let data: Record | undefined + let stack: string | undefined + + // Processar objeto + 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), + } + } else if (typeof obj === 'object' && obj !== null) { + data = this.sanitize(obj as Record) + if (!message && 'msg' in (obj as Record)) { + message = String((obj as Record).msg) + } + } else if (typeof obj === 'string') { + message = message || obj + } + + // Extrair correlationId e durationMs se presentes + 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, + } + } + + /** + * Sanitiza dados sensíveis + */ + private sanitize(obj: Record): Record { + const sanitized: Record = {} + + 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) + } else { + sanitized[key] = value + } + } + + return sanitized + } + + /** + * Atualiza métricas internas + */ + 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 do log + */ + 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 + } + } + + /** + * Formata log como texto legível + */ + 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 + } + + // Métodos de conveniência para cada nível de log + + 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 com contexto temporário + */ + withContext(context: Record): StructuredLogger { + return this.child(context) + } + + /** + * Log com correlation ID + */ + withCorrelationId(correlationId: string): StructuredLogger { + return this.child({ correlationId }) + } + + /** + * Log de operação com duração + */ + logOperation( + operationName: string, + operation: () => T | Promise, + level: LogLevel = 'info' + ): T | Promise { + 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 + } + + return handleResult(result) + } catch (error) { + return handleError(error as Error) + } + } + + /** + * Retorna métricas do logger + */ + getMetrics(): LoggerMetrics { + return { ...this.metrics } + } + + /** + * Reseta métricas + */ + resetMetrics(): void { + this.metrics = { + totalLogs: 0, + logsByLevel: { + trace: 0, + debug: 0, + info: 0, + warn: 0, + error: 0, + fatal: 0, + silent: 0, + }, + errorsCount: 0, + } + } +} + +/** + * Factory para criar logger estruturado + */ +export function createStructuredLogger(config: Partial = {}): StructuredLogger { + return new StructuredLogger({ + level: config.level || 'info', + ...config, + }) +} + +/** + * Logger padrão singleton + */ +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 +} + +/** + * Utilitário para medir tempo de execução + */ +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 diff --git a/src/Utils/trace-context.ts b/src/Utils/trace-context.ts new file mode 100644 index 00000000..dd76c76b --- /dev/null +++ b/src/Utils/trace-context.ts @@ -0,0 +1,656 @@ +/** + * @fileoverview Contexto de rastreamento para requests + * @module Utils/trace-context + * + * Fornece: + * - Geração de trace IDs únicos + * - Context propagation entre operações + * - Correlation IDs para rastrear requests + * - Performance timing + * - Span tracking para operações aninhadas + * - Baggage para dados contextuais + */ + +import { randomBytes } from 'crypto' +import { AsyncLocalStorage } from 'async_hooks' + +/** + * Identificadores de trace + */ +export interface TraceIds { + /** ID único do trace (16 bytes hex) */ + traceId: string + /** ID do span atual (8 bytes hex) */ + spanId: string + /** ID do span pai (opcional) */ + parentSpanId?: string + /** ID de correlação para logging */ + correlationId: string +} + +/** + * Dados de baggage (contexto propagado) + */ +export type Baggage = Record + +/** + * Status de um span + */ +export type SpanStatus = 'unset' | 'ok' | 'error' + +/** + * Span representa uma unidade de trabalho + */ +export interface Span { + /** Nome da operação */ + name: string + /** IDs de rastreamento */ + traceIds: TraceIds + /** Timestamp de início (ms) */ + startTime: number + /** Timestamp de fim (ms) */ + endTime?: number + /** Duração em ms */ + duration?: number + /** Status do span */ + status: SpanStatus + /** Atributos do span */ + attributes: Record + /** Eventos ocorridos durante o span */ + events: SpanEvent[] + /** Se o span está finalizado */ + ended: boolean +} + +/** + * Evento dentro de um span + */ +export interface SpanEvent { + /** Nome do evento */ + name: string + /** Timestamp do evento */ + timestamp: number + /** Atributos do evento */ + attributes?: Record +} + +/** + * Contexto completo de trace + */ +export interface TraceContext { + /** IDs de rastreamento */ + traceIds: TraceIds + /** Baggage (dados propagados) */ + baggage: Baggage + /** Span atual */ + currentSpan?: Span + /** Stack de spans (para spans aninhados) */ + spanStack: Span[] + /** Timestamp de criação do contexto */ + createdAt: number + /** Metadados adicionais */ + metadata: Record +} + +/** + * Opções para criar um novo contexto + */ +export interface CreateContextOptions { + /** Trace ID existente (para propagação) */ + traceId?: string + /** Parent span ID */ + parentSpanId?: string + /** Correlation ID existente */ + correlationId?: string + /** Baggage inicial */ + baggage?: Baggage + /** Metadados iniciais */ + metadata?: Record +} + +/** + * Opções para criar um span + */ +export interface CreateSpanOptions { + /** Nome do span */ + name: string + /** Atributos iniciais */ + attributes?: Record + /** Se deve ser filho do span atual */ + asChild?: boolean +} + +/** + * Storage assíncrono para contexto de trace + */ +const traceStorage = new AsyncLocalStorage() + +/** + * Gera um ID hexadecimal aleatório + */ +function generateId(bytes: number): string { + return randomBytes(bytes).toString('hex') +} + +/** + * Gera um trace ID (16 bytes = 32 chars hex) + */ +export function generateTraceId(): string { + return generateId(16) +} + +/** + * Gera um span ID (8 bytes = 16 chars hex) + */ +export function generateSpanId(): string { + return generateId(8) +} + +/** + * Gera um correlation ID mais legível + */ +export function generateCorrelationId(): string { + const timestamp = Date.now().toString(36) + const random = generateId(4) + return `${timestamp}-${random}` +} + +/** + * Cria um novo contexto de trace + */ +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 || {}, + } +} + +/** + * Obtém o contexto de trace atual + */ +export function getCurrentContext(): TraceContext | undefined { + return traceStorage.getStore() +} + +/** + * Obtém o contexto de trace atual ou cria um novo + */ +export function getOrCreateContext(): TraceContext { + const existing = getCurrentContext() + if (existing) { + return existing + } + return createTraceContext() +} + +/** + * Executa função com contexto de trace + */ +export function runWithContext(context: TraceContext, fn: () => T): T { + return traceStorage.run(context, fn) +} + +/** + * Executa função com novo contexto de trace + */ +export function runWithNewContext(options: CreateContextOptions, fn: () => T): T { + const context = createTraceContext(options) + return runWithContext(context, fn) +} + +/** + * Executa função assíncrona com contexto de trace + */ +export async function runWithContextAsync( + context: TraceContext, + fn: () => Promise +): Promise { + return traceStorage.run(context, fn) +} + +/** + * Cria um novo 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 +} + +/** + * Inicia um span no contexto atual + */ +export function startSpan(options: CreateSpanOptions): Span { + const context = getOrCreateContext() + const span = createSpan({ ...options, asChild: true }) + + // Push span atual para stack e define novo como atual + if (context.currentSpan) { + context.spanStack.push(context.currentSpan) + } + context.currentSpan = span + + return span +} + +/** + * Finaliza um 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 do stack no contexto + const context = getCurrentContext() + if (context && context.currentSpan === span) { + context.currentSpan = context.spanStack.pop() + } +} + +/** + * Adiciona evento a um span + */ +export function addSpanEvent(span: Span, name: string, attributes?: Record): void { + if (span.ended) { + return + } + + span.events.push({ + name, + timestamp: Date.now(), + attributes, + }) +} + +/** + * Define atributos em um span + */ +export function setSpanAttributes(span: Span, attributes: Record): void { + if (span.ended) { + return + } + + Object.assign(span.attributes, attributes) +} + +/** + * Marca span como erro + */ +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 para rastrear função automaticamente + */ +export function traced(name?: string) { + return function unknown>( + _target: unknown, + propertyKey: string, + descriptor: TypedPropertyDescriptor + ): TypedPropertyDescriptor { + const originalMethod = descriptor.value + if (!originalMethod) { + return descriptor + } + + const spanName = name || propertyKey + + descriptor.value = function (this: unknown, ...args: Parameters): ReturnType { + const span = startSpan({ name: spanName }) + + try { + const result = originalMethod.apply(this, args) as ReturnType + + 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 + } + + endSpan(span, 'ok') + return result + } catch (error) { + setSpanError(span, error as Error) + endSpan(span, 'error') + throw error + } + } as T + + return descriptor + } +} + +/** + * Wrapper para rastrear função + */ +export function traceFunction unknown>( + name: string, + fn: T +): T { + return function (this: unknown, ...args: Parameters): ReturnType { + const span = startSpan({ name }) + + try { + const result = fn.apply(this, args) as ReturnType + + 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 + } + + endSpan(span, 'ok') + return result + } catch (error) { + setSpanError(span, error as Error) + endSpan(span, 'error') + throw error + } + } as T +} + +/** + * Executa operação com span automático + */ +export async function withSpan( + name: string, + operation: (span: Span) => Promise, + attributes?: Record +): Promise { + 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 + } +} + +/** + * Executa operação síncrona com span automático + */ +export function withSpanSync( + name: string, + operation: (span: Span) => T, + attributes?: Record +): 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 + } +} + +// === Gerenciamento de Baggage === + +/** + * Define item no baggage + */ +export function setBaggage(key: string, value: string | number | boolean): void { + const context = getCurrentContext() + if (context) { + context.baggage[key] = value + } +} + +/** + * Obtém item do baggage + */ +export function getBaggage(key: string): string | number | boolean | undefined { + const context = getCurrentContext() + return context?.baggage[key] +} + +/** + * Obtém todo o baggage + */ +export function getAllBaggage(): Baggage { + const context = getCurrentContext() + return context?.baggage || {} +} + +/** + * Remove item do baggage + */ +export function removeBaggage(key: string): void { + const context = getCurrentContext() + if (context) { + delete context.baggage[key] + } +} + +// === Utilitários de Headers === + +/** + * Headers padrão para propagação de trace + */ +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 + +/** + * Injeta contexto em headers HTTP + */ +export function injectTraceHeaders(headers: Record): Record { + 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 como lista de key=value + 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 +} + +/** + * Extrai contexto de headers HTTP + */ +export function extractTraceHeaders(headers: Record): 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 +} + +/** + * Exporta trace context para serialização + */ +export function exportContext(context: TraceContext): string { + return JSON.stringify({ + traceIds: context.traceIds, + baggage: context.baggage, + metadata: context.metadata, + }) +} + +/** + * Importa trace context de string serializada + */ +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 === + +/** + * Timer de alta precisão + */ +export interface PrecisionTimer { + /** Retorna tempo decorrido em milliseconds */ + elapsed(): number + /** Retorna tempo decorrido formatado */ + elapsedFormatted(): string + /** Para o timer e retorna duração */ + stop(): number +} + +/** + * Cria um timer de alta precisão + */ +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, +} diff --git a/src/__tests__/Utils/baileys-event-stream.test.ts b/src/__tests__/Utils/baileys-event-stream.test.ts new file mode 100644 index 00000000..a4c0bb5e --- /dev/null +++ b/src/__tests__/Utils/baileys-event-stream.test.ts @@ -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') + }) + }) +}) diff --git a/src/__tests__/Utils/cache-utils.test.ts b/src/__tests__/Utils/cache-utils.test.ts new file mode 100644 index 00000000..5907edf4 --- /dev/null +++ b/src/__tests__/Utils/cache-utils.test.ts @@ -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 + + beforeEach(() => { + cache = createCache({ + 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({ + 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( + { 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( + { 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( + { 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('test1') + const cache2 = getGlobalCache('test2') + + cache1.set('key', 'value') + cache2.set('key', 'value') + + clearGlobalCaches() + + // After clear, new caches should be created + const newCache1 = getGlobalCache('test1') + expect(newCache1.get('key')).toBeUndefined() + }) +}) diff --git a/src/__tests__/Utils/circuit-breaker.test.ts b/src/__tests__/Utils/circuit-breaker.test.ts new file mode 100644 index 00000000..bd5f5392 --- /dev/null +++ b/src/__tests__/Utils/circuit-breaker.test.ts @@ -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') + }) +}) diff --git a/src/__tests__/Utils/retry-utils.test.ts b/src/__tests__/Utils/retry-utils.test.ts new file mode 100644 index 00000000..9e9df2bf --- /dev/null +++ b/src/__tests__/Utils/retry-utils.test.ts @@ -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((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) + }) +}) diff --git a/src/__tests__/Utils/structured-logger.test.ts b/src/__tests__/Utils/structured-logger.test.ts new file mode 100644 index 00000000..1c9bf310 --- /dev/null +++ b/src/__tests__/Utils/structured-logger.test.ts @@ -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 + + 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) + }) + }) +}) diff --git a/src/__tests__/Utils/trace-context.test.ts b/src/__tests__/Utils/trace-context.test.ts new file mode 100644 index 00000000..f04794bc --- /dev/null +++ b/src/__tests__/Utils/trace-context.test.ts @@ -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) + }) +})